diff --git a/cli/command/service/helpers.go b/cli/command/service/helpers.go index 50aee7c88f..2e893f96e1 100644 --- a/cli/command/service/helpers.go +++ b/cli/command/service/helpers.go @@ -2,6 +2,7 @@ package service import ( "io" + "io/ioutil" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command/service/progress" @@ -20,14 +21,7 @@ func waitOnService(ctx context.Context, dockerCli *command.DockerCli, serviceID }() if opts.quiet { - go func() { - for { - var buf [1024]byte - if _, err := pipeReader.Read(buf[:]); err != nil { - return - } - } - }() + go io.Copy(ioutil.Discard, pipeReader) return <-errChan } diff --git a/cli/command/swarm/ca.go b/cli/command/swarm/ca.go new file mode 100644 index 0000000000..fab0929b64 --- /dev/null +++ b/cli/command/swarm/ca.go @@ -0,0 +1,128 @@ +package swarm + +import ( + "fmt" + "io" + "strings" + + "golang.org/x/net/context" + + "io/ioutil" + + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/swarm/progress" + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/pkg/jsonmessage" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +type caOptions struct { + swarmOptions + rootCACert PEMFile + rootCAKey PEMFile + rotate bool + detach bool + quiet bool +} + +func newRotateCACommand(dockerCli command.Cli) *cobra.Command { + opts := caOptions{} + + cmd := &cobra.Command{ + Use: "ca [OPTIONS]", + Short: "Manage root CA", + Args: cli.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runRotateCA(dockerCli, cmd.Flags(), opts) + }, + Tags: map[string]string{"version": "1.30"}, + } + + flags := cmd.Flags() + addSwarmCAFlags(flags, &opts.swarmOptions) + flags.BoolVar(&opts.rotate, flagRotate, false, "Rotate the swarm CA - if no certificate or key are provided, new ones will be generated") + flags.Var(&opts.rootCACert, flagCACert, "Path to the PEM-formatted root CA certificate to use for the new cluster") + flags.Var(&opts.rootCAKey, flagCAKey, "Path to the PEM-formatted root CA key to use for the new cluster") + + flags.BoolVarP(&opts.detach, "detach", "d", false, "Exit immediately instead of waiting for the root rotation to converge") + flags.BoolVarP(&opts.quiet, "quiet", "q", false, "Suppress progress output") + return cmd +} + +func runRotateCA(dockerCli command.Cli, flags *pflag.FlagSet, opts caOptions) error { + client := dockerCli.Client() + ctx := context.Background() + + swarmInspect, err := client.SwarmInspect(ctx) + if err != nil { + return err + } + + if !opts.rotate { + if swarmInspect.ClusterInfo.TLSInfo.TrustRoot == "" { + fmt.Fprintln(dockerCli.Out(), "No CA information available") + } else { + fmt.Fprintln(dockerCli.Out(), strings.TrimSpace(swarmInspect.ClusterInfo.TLSInfo.TrustRoot)) + } + return nil + } + + genRootCA := true + spec := &swarmInspect.Spec + opts.mergeSwarmSpec(spec, flags) + if flags.Changed(flagCACert) { + spec.CAConfig.SigningCACert = opts.rootCACert.Contents() + genRootCA = false + } + if flags.Changed(flagCAKey) { + spec.CAConfig.SigningCAKey = opts.rootCAKey.Contents() + genRootCA = false + } + if genRootCA { + spec.CAConfig.ForceRotate++ + spec.CAConfig.SigningCACert = "" + spec.CAConfig.SigningCAKey = "" + } + + if err := client.SwarmUpdate(ctx, swarmInspect.Version, swarmInspect.Spec, swarm.UpdateFlags{}); err != nil { + return err + } + + if opts.detach { + return nil + } + + errChan := make(chan error, 1) + pipeReader, pipeWriter := io.Pipe() + + go func() { + errChan <- progress.RootRotationProgress(ctx, client, pipeWriter) + }() + + if opts.quiet { + go io.Copy(ioutil.Discard, pipeReader) + return <-errChan + } + + err = jsonmessage.DisplayJSONMessagesToStream(pipeReader, dockerCli.Out(), nil) + if err == nil { + err = <-errChan + } + if err != nil { + return err + } + + swarmInspect, err = client.SwarmInspect(ctx) + if err != nil { + return err + } + + if swarmInspect.ClusterInfo.TLSInfo.TrustRoot == "" { + fmt.Fprintln(dockerCli.Out(), "No CA information available") + } else { + fmt.Fprintln(dockerCli.Out(), strings.TrimSpace(swarmInspect.ClusterInfo.TLSInfo.TrustRoot)) + } + return nil +} diff --git a/cli/command/swarm/cmd.go b/cli/command/swarm/cmd.go index 07cb302c62..b7e6dcfda2 100644 --- a/cli/command/swarm/cmd.go +++ b/cli/command/swarm/cmd.go @@ -25,6 +25,7 @@ func NewSwarmCommand(dockerCli command.Cli) *cobra.Command { newUpdateCommand(dockerCli), newLeaveCommand(dockerCli), newUnlockCommand(dockerCli), + newRotateCACommand(dockerCli), ) return cmd } diff --git a/cli/command/swarm/opts.go b/cli/command/swarm/opts.go index 868605e6d0..4625835055 100644 --- a/cli/command/swarm/opts.go +++ b/cli/command/swarm/opts.go @@ -31,6 +31,8 @@ const ( flagSnapshotInterval = "snapshot-interval" flagAutolock = "autolock" flagAvailability = "availability" + flagCACert = "ca-cert" + flagCAKey = "ca-key" ) type swarmOptions struct { @@ -119,6 +121,39 @@ func (m *ExternalCAOption) Value() []*swarm.ExternalCA { return m.values } +// PEMFile represents the path to a pem-formatted file +type PEMFile struct { + path, contents string +} + +// Type returns the type of this option. +func (p *PEMFile) Type() string { + return "pem-file" +} + +// String returns the path to the pem file +func (p *PEMFile) String() string { + return p.path +} + +// Set parses a root rotation option +func (p *PEMFile) Set(value string) error { + contents, err := ioutil.ReadFile(value) + if err != nil { + return err + } + if pemBlock, _ := pem.Decode(contents); pemBlock == nil { + return errors.New("file contents must be in PEM format") + } + p.contents, p.path = string(contents), value + return nil +} + +// Contents returns the contents of the PEM file +func (p *PEMFile) Contents() string { + return p.contents +} + // parseExternalCA parses an external CA specification from the command line, // such as protocol=cfssl,url=https://example.com. func parseExternalCA(caSpec string) (*swarm.ExternalCA, error) { @@ -181,15 +216,19 @@ func parseExternalCA(caSpec string) (*swarm.ExternalCA, error) { return &externalCA, nil } +func addSwarmCAFlags(flags *pflag.FlagSet, opts *swarmOptions) { + flags.DurationVar(&opts.nodeCertExpiry, flagCertExpiry, time.Duration(90*24*time.Hour), "Validity period for node certificates (ns|us|ms|s|m|h)") + flags.Var(&opts.externalCA, flagExternalCA, "Specifications of one or more certificate signing endpoints") +} + func addSwarmFlags(flags *pflag.FlagSet, opts *swarmOptions) { flags.Int64Var(&opts.taskHistoryLimit, flagTaskHistoryLimit, 5, "Task history retention limit") flags.DurationVar(&opts.dispatcherHeartbeat, flagDispatcherHeartbeat, time.Duration(5*time.Second), "Dispatcher heartbeat period (ns|us|ms|s|m|h)") - flags.DurationVar(&opts.nodeCertExpiry, flagCertExpiry, time.Duration(90*24*time.Hour), "Validity period for node certificates (ns|us|ms|s|m|h)") - flags.Var(&opts.externalCA, flagExternalCA, "Specifications of one or more certificate signing endpoints") flags.Uint64Var(&opts.maxSnapshots, flagMaxSnapshots, 0, "Number of additional Raft snapshots to retain") flags.SetAnnotation(flagMaxSnapshots, "version", []string{"1.25"}) flags.Uint64Var(&opts.snapshotInterval, flagSnapshotInterval, 10000, "Number of log entries between Raft snapshots") flags.SetAnnotation(flagSnapshotInterval, "version", []string{"1.25"}) + addSwarmCAFlags(flags, opts) } func (opts *swarmOptions) mergeSwarmSpec(spec *swarm.Spec, flags *pflag.FlagSet) { diff --git a/cli/command/swarm/progress/root_rotation.go b/cli/command/swarm/progress/root_rotation.go new file mode 100644 index 0000000000..0b84d239e3 --- /dev/null +++ b/cli/command/swarm/progress/root_rotation.go @@ -0,0 +1,121 @@ +package progress + +import ( + "bytes" + "io" + "os" + "os/signal" + "time" + + "golang.org/x/net/context" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/client" + "github.com/docker/docker/pkg/progress" + "github.com/docker/docker/pkg/streamformatter" + "github.com/opencontainers/go-digest" +) + +const ( + certsRotatedStr = " rotated TLS certificates" + rootsRotatedStr = " rotated CA certificates" + // rootsAction has a single space because rootsRotatedStr is one character shorter than certsRotatedStr. + // This makes sure the progress bar are aligned. + certsAction = "" + rootsAction = " " +) + +// RootRotationProgress outputs progress information for convergence of a root rotation. +func RootRotationProgress(ctx context.Context, dclient client.APIClient, progressWriter io.WriteCloser) error { + defer progressWriter.Close() + + progressOut := streamformatter.NewJSONProgressOutput(progressWriter, false) + + sigint := make(chan os.Signal, 1) + signal.Notify(sigint, os.Interrupt) + defer signal.Stop(sigint) + + // draw 2 progress bars, 1 for nodes with the correct cert, 1 for nodes with the correct trust root + progress.Update(progressOut, "desired root digest", "") + progress.Update(progressOut, certsRotatedStr, certsAction) + progress.Update(progressOut, rootsRotatedStr, rootsAction) + + var done bool + + for { + info, err := dclient.SwarmInspect(ctx) + if err != nil { + return err + } + + if done { + return nil + } + + nodes, err := dclient.NodeList(ctx, types.NodeListOptions{}) + if err != nil { + return err + } + + done = updateProgress(progressOut, info.ClusterInfo.TLSInfo, nodes, info.ClusterInfo.RootRotationInProgress) + + select { + case <-time.After(200 * time.Millisecond): + case <-sigint: + if !done { + progress.Message(progressOut, "", "Operation continuing in background.") + progress.Message(progressOut, "", "Use `swarmctl cluster inspect default` to check progress.") + } + return nil + } + } +} + +func updateProgress(progressOut progress.Output, desiredTLSInfo swarm.TLSInfo, nodes []swarm.Node, rootRotationInProgress bool) bool { + // write the current desired root cert's digest, because the desired root certs might be too long + progressOut.WriteProgress(progress.Progress{ + ID: "desired root digest", + Action: digest.FromBytes([]byte(desiredTLSInfo.TrustRoot)).String(), + }) + + // If we had reached a converged state, check if we are still converged. + var certsRight, trustRootsRight int64 + for _, n := range nodes { + if bytes.Equal(n.Description.TLSInfo.CertIssuerPublicKey, desiredTLSInfo.CertIssuerPublicKey) && + bytes.Equal(n.Description.TLSInfo.CertIssuerSubject, desiredTLSInfo.CertIssuerSubject) { + certsRight++ + } + + if n.Description.TLSInfo.TrustRoot == desiredTLSInfo.TrustRoot { + trustRootsRight++ + } + } + + total := int64(len(nodes)) + progressOut.WriteProgress(progress.Progress{ + ID: certsRotatedStr, + Action: certsAction, + Current: certsRight, + Total: total, + Units: "nodes", + }) + + rootsProgress := progress.Progress{ + ID: rootsRotatedStr, + Action: rootsAction, + Current: trustRootsRight, + Total: total, + Units: "nodes", + } + + if certsRight == total && !rootRotationInProgress { + progressOut.WriteProgress(rootsProgress) + return certsRight == total && trustRootsRight == total + } + + // we still have certs that need renewing, so display that there are zero roots rotated yet + rootsProgress.Current = 0 + progressOut.WriteProgress(rootsProgress) + return false +} diff --git a/cli/command/system/info.go b/cli/command/system/info.go index efb3c4466b..7b91066974 100644 --- a/cli/command/system/info.go +++ b/cli/command/system/info.go @@ -123,6 +123,8 @@ func prettyPrintInfo(dockerCli *command.DockerCli, info types.Info) error { fmt.Fprintf(dockerCli.Out(), " Heartbeat Period: %s\n", units.HumanDuration(time.Duration(info.Swarm.Cluster.Spec.Dispatcher.HeartbeatPeriod))) fmt.Fprintf(dockerCli.Out(), " CA Configuration:\n") fmt.Fprintf(dockerCli.Out(), " Expiry Duration: %s\n", units.HumanDuration(info.Swarm.Cluster.Spec.CAConfig.NodeCertExpiry)) + fmt.Fprintf(dockerCli.Out(), " Force Rotate: %d\n", info.Swarm.Cluster.Spec.CAConfig.ForceRotate) + fprintfIfNotEmpty(dockerCli.Out(), " Signing CA Certificate: \n%s\n\n", strings.TrimSpace(info.Swarm.Cluster.Spec.CAConfig.SigningCACert)) if len(info.Swarm.Cluster.Spec.CAConfig.ExternalCAs) > 0 { fmt.Fprintf(dockerCli.Out(), " External CAs:\n") for _, entry := range info.Swarm.Cluster.Spec.CAConfig.ExternalCAs { diff --git a/vendor.conf b/vendor.conf index 658f3d365b..93924ecebc 100644 --- a/vendor.conf +++ b/vendor.conf @@ -6,7 +6,7 @@ github.com/agl/ed25519 d2b94fd789ea21d12fac1a4443dd3a3f79cda72c github.com/coreos/etcd 824277cb3a577a0e8c829ca9ec557b973fe06d20 github.com/davecgh/go-spew 346938d642f2ec3594ed81d874461961cd0faa76 github.com/docker/distribution b38e5838b7b2f2ad48e06ec4b500011976080621 -github.com/docker/docker 69c35dad8e7ec21de32d42b9dd606d3416ae1566 +github.com/docker/docker eb8abc95985bf3882a4a177c409a96e36e25f5b7 github.com/docker/docker-credential-helpers v0.5.0 github.com/docker/go d30aec9fd63c35133f8f79c3412ad91a3b08be06 github.com/docker/go-connections e15c02316c12de00874640cd76311849de2aeed5 diff --git a/vendor/github.com/docker/docker/api/types/swarm/swarm.go b/vendor/github.com/docker/docker/api/types/swarm/swarm.go index bdb3042337..5b74f14b11 100644 --- a/vendor/github.com/docker/docker/api/types/swarm/swarm.go +++ b/vendor/github.com/docker/docker/api/types/swarm/swarm.go @@ -109,6 +109,16 @@ type CAConfig struct { // ExternalCAs is a list of CAs to which a manager node will make // certificate signing requests for node certificates. ExternalCAs []*ExternalCA `json:",omitempty"` + + // SigningCACert and SigningCAKey specify the desired signing root CA and + // root CA key for the swarm. When inspecting the cluster, the key will + // be redacted. + SigningCACert string `json:",omitempty"` + SigningCAKey string `json:",omitempty"` + + // If this value changes, and there is no specified signing cert and key, + // then the swarm is forced to generate a new root certificate ane key. + ForceRotate uint64 `json:",omitempty"` } // ExternalCAProtocol represents type of external CA. diff --git a/vendor/github.com/docker/docker/pkg/jsonmessage/jsonmessage.go b/vendor/github.com/docker/docker/pkg/jsonmessage/jsonmessage.go index 2b8e98c429..dc785d6187 100644 --- a/vendor/github.com/docker/docker/pkg/jsonmessage/jsonmessage.go +++ b/vendor/github.com/docker/docker/pkg/jsonmessage/jsonmessage.go @@ -36,7 +36,8 @@ type JSONProgress struct { Total int64 `json:"total,omitempty"` Start int64 `json:"start,omitempty"` // If true, don't show xB/yB - HideCounts bool `json:"hidecounts,omitempty"` + HideCounts bool `json:"hidecounts,omitempty"` + Units string `json:"units,omitempty"` } func (p *JSONProgress) String() string { @@ -55,11 +56,16 @@ func (p *JSONProgress) String() string { if p.Current <= 0 && p.Total <= 0 { return "" } - current := units.HumanSize(float64(p.Current)) if p.Total <= 0 { - return fmt.Sprintf("%8v", current) + switch p.Units { + case "": + current := units.HumanSize(float64(p.Current)) + return fmt.Sprintf("%8v", current) + default: + return fmt.Sprintf("%d %s", p.Current, p.Units) + } } - total := units.HumanSize(float64(p.Total)) + percentage := int(float64(p.Current)/float64(p.Total)*100) / 2 if percentage > 50 { percentage = 50 @@ -73,13 +79,25 @@ func (p *JSONProgress) String() string { pbBox = fmt.Sprintf("[%s>%s] ", strings.Repeat("=", percentage), strings.Repeat(" ", numSpaces)) } - if !p.HideCounts { + switch { + case p.HideCounts: + case p.Units == "": // no units, use bytes + current := units.HumanSize(float64(p.Current)) + total := units.HumanSize(float64(p.Total)) + numbersBox = fmt.Sprintf("%8v/%v", current, total) if p.Current > p.Total { // remove total display if the reported current is wonky. numbersBox = fmt.Sprintf("%8v", current) } + default: + numbersBox = fmt.Sprintf("%d/%d %s", p.Current, p.Total, p.Units) + + if p.Current > p.Total { + // remove total display if the reported current is wonky. + numbersBox = fmt.Sprintf("%d %s", p.Current, p.Units) + } } if p.Current > 0 && p.Start > 0 && percentage < 50 { diff --git a/vendor/github.com/docker/docker/pkg/progress/progress.go b/vendor/github.com/docker/docker/pkg/progress/progress.go index e78fc120b6..7c3d3a5145 100644 --- a/vendor/github.com/docker/docker/pkg/progress/progress.go +++ b/vendor/github.com/docker/docker/pkg/progress/progress.go @@ -18,6 +18,8 @@ type Progress struct { // If true, don't show xB/yB HideCounts bool + // If not empty, use units instead of bytes for counts + Units string // Aux contains extra information not presented to the user, such as // digests for push signing. diff --git a/vendor/github.com/docker/docker/pkg/streamformatter/streamformatter.go b/vendor/github.com/docker/docker/pkg/streamformatter/streamformatter.go index 48ba65503c..c4f55755ec 100644 --- a/vendor/github.com/docker/docker/pkg/streamformatter/streamformatter.go +++ b/vendor/github.com/docker/docker/pkg/streamformatter/streamformatter.go @@ -117,7 +117,7 @@ func (out *progressOutput) WriteProgress(prog progress.Progress) error { if prog.Message != "" { formatted = out.sf.formatStatus(prog.ID, prog.Message) } else { - jsonProgress := jsonmessage.JSONProgress{Current: prog.Current, Total: prog.Total, HideCounts: prog.HideCounts} + jsonProgress := jsonmessage.JSONProgress{Current: prog.Current, Total: prog.Total, HideCounts: prog.HideCounts, Units: prog.Units} formatted = out.sf.formatProgress(prog.ID, prog.Action, &jsonProgress, prog.Aux) } _, err := out.out.Write(formatted)