Skip to content

Conversation

@silvin-lubecki
Copy link
Contributor

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

@codecov-io
Copy link

codecov-io commented Jan 23, 2019

Codecov Report

Merging #1633 into master will increase coverage by 0.2%.
The diff coverage is 59.21%.

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

Copy link
Member

@thaJeztah thaJeztah left a 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 👍

}

// DockerCliOperator applies a modification on a DockerCli.
type DockerCliOperator func(cli *DockerCli)
Copy link
Member

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?

Copy link
Collaborator

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 🙃

Copy link
Contributor Author

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 👍

Copy link
Contributor

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)

Copy link
Member

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?

Copy link
Contributor

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.

Copy link
Contributor

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

return cli
}

// WithStandardStreams sets a cli in, out and err streams with the standard streams.
Copy link
Member

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)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll do that 👍

Copy link
Collaborator

@vdemeester vdemeester left a 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 !

}

// DockerCliOperator applies a modification on a DockerCli.
type DockerCliOperator func(cli *DockerCli)
Copy link
Collaborator

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{}
Copy link
Contributor

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

Copy link
Contributor Author

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
}

Copy link
Contributor Author

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.

Copy link
Member

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

}

// WithContentTrust enables content trust on a cli.
func WithContentTrust() DockerCliOperator {
Copy link
Contributor

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.

Copy link
Contributor

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed 👍

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)))
Copy link
Contributor

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.

Copy link
Contributor

@simonferquel simonferquel left a 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

}

// DockerCliOperator applies a modification on a DockerCli.
type DockerCliOperator func(cli *DockerCli)
Copy link
Contributor

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.

}

// DockerCliOperator applies a modification on a DockerCli.
type DockerCliOperator func(cli *DockerCli)
Copy link
Contributor

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

}

// WithStandardStreams sets a cli in, out and err streams with the standard streams.
func WithStandardStreams() DockerCliOperator {
Copy link
Contributor

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.

}

// WithContentTrust enables content trust on a cli.
func WithContentTrust() DockerCliOperator {
Copy link
Contributor

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

logrus.SetOutput(os.Stderr)

dockerCli := command.NewDockerCli(
command.WithInputStream(stdin),
Copy link
Contributor

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.

@simonferquel
Copy link
Contributor

lgtm

Copy link
Collaborator

@vdemeester vdemeester left a 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 👼

configFile *configfile.ConfigFile
in *InStream
out *OutStream
in *streams.InStream
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-> streams.In

in *InStream
out *OutStream
in *streams.InStream
out *streams.OutStream
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

streams.Out

return func(cli *DockerCli) error {
// Set terminal emulation based on platform as required.
stdin, stdout, stderr := term.StdStreams()
cli.in = streams.NewInStream(stdin)
Copy link
Contributor

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.

Copy link
Contributor

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

Copy link
Contributor

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)

Copy link
Contributor

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.

Copy link
Member

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)

// 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 {
Copy link
Contributor

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?

Copy link
Contributor Author

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
Copy link
Contributor

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?

Copy link
Contributor Author

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. 👍

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, thanks.

// Set terminal emulation based on platform as required.
stdin, stdout, stderr := term.StdStreams()
logrus.SetOutput(stderr)
logrus.SetOutput(os.Stderr)
Copy link
Contributor

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?

Copy link
Contributor

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@silvin-lubecki silvin-lubecki force-pushed the refactor-docker-cli-construction branch from 45fbbc0 to 1f53b9c Compare January 25, 2019 15:13
return nil, err
}
if cli.out == nil || cli.in == nil {
WithStandardStreams()(cli)
Copy link
Contributor

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

WithStandardStreams()(cli)
}
if cli.err == nil {
cli.err = ioutil.Discard
Copy link
Contributor

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

Copy link
Contributor Author

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.

@silvin-lubecki
Copy link
Contributor Author

@simonferquel @thaJeztah @vdemeester @ijc Please take another look 😺

@silvin-lubecki
Copy link
Contributor Author

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
Copy link
Contributor

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?

Copy link
Contributor Author

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?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SGTM.

Copy link
Collaborator

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…

@silvin-lubecki silvin-lubecki force-pushed the refactor-docker-cli-construction branch from f6edd15 to 14d21b7 Compare January 27, 2019 22:09
Copy link
Collaborator

@vdemeester vdemeester left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🐯

Copy link
Contributor

@simonferquel simonferquel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Copy link
Contributor

@ijc ijc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Copy link
Member

@thaJeztah thaJeztah left a 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>
@silvin-lubecki silvin-lubecki force-pushed the refactor-docker-cli-construction branch from 9415339 to 7f207f3 Compare January 28, 2019 13:54
@silvin-lubecki
Copy link
Contributor Author

@thaJeztah just squashed and re-organized the commits. PTAL 🐱

@vdemeester
Copy link
Collaborator

yay 🎉

@vdemeester vdemeester merged commit f95ca8e into docker:master Jan 28, 2019
@GordonTheTurtle GordonTheTurtle added this to the 19.03.0 milestone Jan 28, 2019
@silvin-lubecki silvin-lubecki deleted the refactor-docker-cli-construction branch January 28, 2019 14:51
logrus.SetOutput(stderr)
dockerCli, err := command.NewDockerCli()
if err != nil {
fmt.Fprintln(dockerCli.Err(), err)
Copy link
Contributor

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.

Copy link
Contributor Author

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 ! 🤗

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice catch 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed with #1645

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants