-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Introduce functional arguments to NewDockerCli for a more stable API. #1633
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Introduce functional arguments to NewDockerCli for a more stable API. #1633
Conversation
Codecov Report
@@ Coverage Diff @@
## master #1633 +/- ##
=========================================
+ Coverage 55.69% 55.89% +0.2%
=========================================
Files 301 299 -2
Lines 20470 20479 +9
=========================================
+ Hits 11400 11446 +46
+ Misses 8250 8210 -40
- Partials 820 823 +3 |
thaJeztah
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks! 😍
left some comments inline; after this is merged, we can go through other options to see if we can migrate more options to functional arguments 👍
cli/command/cli.go
Outdated
| } | ||
|
|
||
| // DockerCliOperator applies a modification on a DockerCli. | ||
| type DockerCliOperator func(cli *DockerCli) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
perhaps s/DockerCliOperator/DockerCliOption/ ?
note that strictly, we should've used CLI (all caps), but that ship has likely sailed 😓
One thing I'm thinking; should we change the signature to return an error? This would allow us to capture possible errors in the configuration (which could be if these functional options take (e.g.) configuration from something else, and there's an issue with the configuration, or (e.g.) if there's conflicting options set
@vdemeester @ijc wdyt?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do agree on the "return error" 👼 😉. I do like Operator somehow 🙃
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 for the renaming.
About the error, I thought about that too, I just wondered it may modify too much code for the caller, as suddenly it has to return and propagate an error, if it didn't already. But ok I'll add that 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that this func typedef should certainly return an error, but I don't think it necessarily has to follow that NewDockerCli needs to propagate it for the user to handle, it could be swallowed by generating a log message and an os.Exit inside that function. I'm not sure I like that but it's an option to consider.
One, ugly, alternative would be to stash the error and return from Initialize instead -- that sounds pretty gross but it would mean deferring the error (or even the processing of the options entirely) until the DockerCli was actually about to be used -- not sure how many users unconditionally make a DockerCli on init but make a more dynamic decision about whether to use (and this Initialize) it further down the line. I suspect not too many (in which case I think this is a terrible idea...)
I thought this sort of thing was customarily called a [fF]ooOption (or even Opt) rather than Operator. Maybe I'm wrong but:
$ pwd
.../gocode/src/github.com/docker/cli
$ git grep type.*func.*Oper
$ git grep type.*func.*Opt
cli/compose/loader/loader.go:func Load(configDetails types.ConfigDetails, options ...func(*Options)) (*types.Config, error) {
vendor/github.com/containerd/containerd/archive/tar_opts.go:type ApplyOpt func(options *ApplyOptions) error
vendor/github.com/containerd/containerd/client_opts.go:type ClientOpt func(c *clientOpts) error
vendor/github.com/containerd/containerd/content/content.go:type WriterOpt func(*WriterOpts) error
vendor/github.com/containerd/containerd/export.go:type ExportOpt func(c *exportOpts) error
vendor/github.com/containerd/containerd/images/image.go:type DeleteOpt func(context.Context, *DeleteOptions) error
vendor/github.com/containerd/containerd/import.go:type ImportOpt func(c *importOpts) error
vendor/github.com/containerd/containerd/leases/lease.go:type DeleteOpt func(context.Context, *DeleteOptions) error
vendor/github.com/containerd/containerd/remotes/docker/schema1/converter.go:type ConvertOpt func(context.Context, *ConvertOptions) error
vendor/google.golang.org/grpc/clientconn.go:type DialOption func(*dialOptions)
vendor/google.golang.org/grpc/interceptor.go:type UnaryInvoker func(ctx context.Context, method string, req, reply interface{}, cc *ClientConn, opts ...CallOption) error
vendor/google.golang.org/grpc/interceptor.go:type UnaryClientInterceptor func(ctx context.Context, method string, req, reply interface{}, cc *ClientConn, invoker UnaryInvoker, opts ...CallOption) error
vendor/google.golang.org/grpc/interceptor.go:type Streamer func(ctx context.Context, desc *StreamDesc, cc *ClientConn, method string, opts ...CallOption) (ClientStream, error)
vendor/google.golang.org/grpc/interceptor.go:type StreamClientInterceptor func(ctx context.Context, desc *StreamDesc, cc *ClientConn, method string, streamer Streamer, opts ...CallOption) (ClientStream, error)
vendor/k8s.io/client-go/tools/cache/listwatch.go:type ListFunc func(options metav1.ListOptions) (runtime.Object, error)
vendor/k8s.io/client-go/tools/cache/listwatch.go:type WatchFunc func(options metav1.ListOptions) (watch.Interface, error)
vendor/k8s.io/client-go/tools/pager/pager.go:type ListPageFunc func(ctx context.Context, opts metav1.ListOptions) (runtime.Object, error)(not all of those are due to this initialiser pattern, FWIW moby/moby and containerd/containerd show a similar pattern)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that this func typedef should certainly return an error, but I don't think it necessarily has to follow that NewDockerCli needs to propagate it for the user to handle
I've seen two patterns; one that never errors, and one that can error. I think having an error is more flexible (errors can be ignored if we don't need them, or a functional option can just return nil)
but make a more dynamic decision about whether to use (and this Initialize) it further down the line.
Right, so I was thinking about that a bit; there's some options that may depend on runtime options (e.g. if a specific flag or environment variable is set) and those have to be evaluated when a command is triggered.
Functions are first-class citizens in Go; should we have something to pass functions that can be executed at runtime (e.g. for the context/config store to be evaluated when the command is run, instead of having to manually call Initialize() before running a command?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having the process exit on NewDockerCli() seem a bit dangerous to me, for 3rd party consumers. I'd vote for NewDockerCli() returning an error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For Post-Initialize functions, we can still use Apply()
cli/command/cli.go
Outdated
| return cli | ||
| } | ||
|
|
||
| // WithStandardStreams sets a cli in, out and err streams with the standard streams. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps we should move all these options to a separate file (e.g. cli/command/cli_options.go or cli/command/options.go)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll do that 👍
vdemeester
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@silvin-lubecki I do like that 😍 a lot !
cli/command/cli.go
Outdated
| } | ||
|
|
||
| // DockerCliOperator applies a modification on a DockerCli. | ||
| type DockerCliOperator func(cli *DockerCli) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do agree on the "return error" 👼 😉. I do like Operator somehow 🙃
| return &DockerCli{in: NewInStream(in), out: NewOutStream(out), err: err, contentTrust: isTrusted, newContainerizeClient: containerizedFn} | ||
| // NewDockerCli returns a DockerCli instance with all operators applied on it. | ||
| func NewDockerCli(ops ...DockerCliOperator) *DockerCli { | ||
| cli := &DockerCli{} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this mean that some ops are always needed (e.g. to provide a stream). Would we want to stub those out to some default (either os.Std* or io.Discard I suppose)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added default operations:
func NewDockerCli(ops ...DockerCliOperator) (*DockerCli, error) {
cli := &DockerCli{}
defaultOps := []DockerCliOperator{
WithStandardStreams(),
WithContentTrustFromEnv(),
WithContainerizedClient(containerizedengine.NewClient),
}
ops = append(defaultOps, ops...)
if err := cli.Apply(ops...); err != nil {
return nil, err
}
return cli, nil
}There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One can override them as users operations are applied after the default ones.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the current approach looks valid; there may other caveats (what if want without containerised client, or without content-trust)"
possible alternatives are;
- only apply defaults if
opsis empty - add a
WithDefaults()option (that's applying these), which can be used if someone wants the defaults plus some custom configurations.
But we can discuss those solutions (not sure which approach makes the most sense)
cli/command/cli.go
Outdated
| } | ||
|
|
||
| // WithContentTrust enables content trust on a cli. | ||
| func WithContentTrust() DockerCliOperator { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FWIW I think the default should be to obey the DOCKER_CONTENT_TRUST variable (encapsulated via contentTrustEnabled() today) and there should be one (or two) of these to force it on or off explicitly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 for default contentTrust value initialized using the env variable, plus to enable/disable explicitly, I would have the signature be func WithContentTrust(enabled bool) DockerCliOperator
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed 👍
cmd/docker/docker_test.go
Outdated
| func TestExitStatusForInvalidSubcommandWithHelpFlag(t *testing.T) { | ||
| discard := ioutil.Discard | ||
| cmd := newDockerCommand(command.NewDockerCli(os.Stdin, discard, discard, false, nil)) | ||
| cmd := newDockerCommand(command.NewDockerCli(command.WithInputStream(os.Stdin))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cf what I wrote above this appears to rely on the streams defaulting to discard.
simonferquel
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some tweaks needed but I like the approach very much
cli/command/cli.go
Outdated
| } | ||
|
|
||
| // DockerCliOperator applies a modification on a DockerCli. | ||
| type DockerCliOperator func(cli *DockerCli) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having the process exit on NewDockerCli() seem a bit dangerous to me, for 3rd party consumers. I'd vote for NewDockerCli() returning an error.
cli/command/cli.go
Outdated
| } | ||
|
|
||
| // DockerCliOperator applies a modification on a DockerCli. | ||
| type DockerCliOperator func(cli *DockerCli) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For Post-Initialize functions, we can still use Apply()
cli/command/cli.go
Outdated
| } | ||
|
|
||
| // WithStandardStreams sets a cli in, out and err streams with the standard streams. | ||
| func WithStandardStreams() DockerCliOperator { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I feel like it should not be an option, and be set by default (the user would use With*Stream to override defaults). Ideally NewDockerCli without parameters should just work.
cli/command/cli.go
Outdated
| } | ||
|
|
||
| // WithContentTrust enables content trust on a cli. | ||
| func WithContentTrust() DockerCliOperator { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 for default contentTrust value initialized using the env variable, plus to enable/disable explicitly, I would have the signature be func WithContentTrust(enabled bool) DockerCliOperator
cmd/docker/docker.go
Outdated
| logrus.SetOutput(os.Stderr) | ||
|
|
||
| dockerCli := command.NewDockerCli( | ||
| command.WithInputStream(stdin), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
within the CLI code, no option should be required. (good defaults for streams, containerizedClient and content trust in the constructor). This is to make it easier for 3rd party to have (by default) the exact same behavior as the CLI itself, and use the options only to customize this behavior.
|
lgtm |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nits 👼
Also, InStream and OutStream should have an type alias in cli/command 👼
cli/command/cli.go
Outdated
| configFile *configfile.ConfigFile | ||
| in *InStream | ||
| out *OutStream | ||
| in *streams.InStream |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
-> streams.In
cli/command/cli.go
Outdated
| in *InStream | ||
| out *OutStream | ||
| in *streams.InStream | ||
| out *streams.OutStream |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
streams.Out
cli/command/cli_options.go
Outdated
| return func(cli *DockerCli) error { | ||
| // Set terminal emulation based on platform as required. | ||
| stdin, stdout, stderr := term.StdStreams() | ||
| cli.in = streams.NewInStream(stdin) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you accept @vdemeester 's suggestion to make the type streams.In you'd want to s/Stream//g here too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
in order not to break (too much) existing code (and I know, this PR WILL break some code), we can alias all the moved Stream related symbols so that command.NewInStream, command.InStream etc still work
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(on the + side, it will make for less code changes in this PR as well)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TBH I'm inclined towards just biting the bullet and making/propagating the change. The necessary updates are pretty mechanical and we should try and avoid building up compatibility debt (which can never be removed) when we don't really need to.
Up to the maintainers though.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If it's a one-time change, I agree with grouping breaking changes together (separate commit though)
cli/command/cli_options.go
Outdated
| // WithContentTrustFromEnv enables content trust on a cli from environment variable DOCKER_CONTENT_TRUST value. | ||
| func WithContentTrustFromEnv() DockerCliOption { | ||
| return func(cli *DockerCli) error { | ||
| cli.contentTrust = func() bool { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the inner func() bool needed for here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well I just moved func contentTrustEnabled() bool from cmd/docker/docker.go to here, but Ok I can simplify it 👍
| assert.NilError(t, err) | ||
| assert.Equal(t, client, nil) | ||
|
|
||
| // Apply can modify a dockerCli after construction |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd be happier that this test was really checking that if it was asserting that the in/out streams had FD() != 0 before this apply.
Oh, except it can be 0 for In anyway, since that is the stdin fd too. Not sure how best to get around that -- perhaps by calling something (like runVersion?) to actually output something into the buf and check it went where you hoped it would?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If fixed that directly reading or writing to the streams and check with their underlying buffers. 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good, thanks.
cmd/docker/docker.go
Outdated
| // Set terminal emulation based on platform as required. | ||
| stdin, stdout, stderr := term.StdStreams() | ||
| logrus.SetOutput(stderr) | ||
| logrus.SetOutput(os.Stderr) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are we happy that this is still the same thing even on Windows? I know that term.StdStreams will still be called implicitly by the following NewDockerCli. I think it's the same fd as you'd get here so maybe^Wprobably that's fine.
One other subtle change here I just realised is that NewDockerCli will now always call term.StdStreams(), which unconditionally does some ioctls on stdio, even if the caller uses their own WithStandardStreams to set something else. I'm not going to pretend I know what those ioctls are doing -- are we sure they are safe and universally desirable?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good call. Can you change the default stdstreams handling in NewDockerCLI to be done at the end, only if there are missing streams (and if only stderr is missng, don't call term.StdStreams()).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
45fbbc0 to
1f53b9c
Compare
cli/command/cli.go
Outdated
| return nil, err | ||
| } | ||
| if cli.out == nil || cli.in == nil { | ||
| WithStandardStreams()(cli) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If only "in" is set, both in and out will be overwritten by WithStandardStream
cli/command/cli.go
Outdated
| WithStandardStreams()(cli) | ||
| } | ||
| if cli.err == nil { | ||
| cli.err = ioutil.Discard |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should default to os.Stderr
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm wondering about that... 🤔 But why not.
|
@simonferquel @thaJeztah @vdemeester @ijc Please take another look 😺 |
|
I'll squash the commits once they are LGTMed |
| ) | ||
|
|
||
| // InStream is an input stream used by the DockerCli to read user input | ||
| // Deprecated: Use github.com/docker/cli/cli/streams.In instead |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When do we expect we would be able to get rid of these if not right now?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we have an official depreciation plan, but I think that 2 releases is a good start, so let's say we will remove them after the 19.09 release? Do you want me to add a comment about it in the code?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SGTM.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ijc it's also a matter of go usage… We do what go does with context and golang.org/x/context. I'm not really sure when (if any time), golang.org/x/context will go…
The hope is that people using those types, will see the deprecation, and update their code…
f6edd15 to
14d21b7
Compare
vdemeester
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM 🐯
simonferquel
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
ijc
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
thaJeztah
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
changes LGTM, but can you squash some of the commits into logical groups (if possible)?
…remove a cyclic dependency from command to internal/containerizedengine Aliasing old types * streams.InStream -> streams.In * streams.NewInStream -> streams.NewIn * streams.OutStream -> streams.Out * streams.NewOutStream -> streams.NewOut Signed-off-by: Silvin Lubecki <silvin.lubecki@docker.com>
Signed-off-by: Silvin Lubecki <silvin.lubecki@docker.com>
9415339 to
7f207f3
Compare
|
@thaJeztah just squashed and re-organized the commits. PTAL 🐱 |
|
yay 🎉 |
| logrus.SetOutput(stderr) | ||
| dockerCli, err := command.NewDockerCli() | ||
| if err != nil { | ||
| fmt.Fprintln(dockerCli.Err(), err) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@silvin-lubecki Sorry for not spotting this before but -- dockerCli will be nil here since this is the error case. I guess os.Stderr is ok in this context.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, will fix that in a follow-up! Thank you for spotting that @ijc ! 🤗
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nice catch 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed with #1645
- What I did
Introduce functional arguments to NewDockerCli for a more stable API.
Add an Apply function to the Cli interface to modify it after construction.
- A picture of a cute animal (not mandatory but encouraged)
