diff --git a/cmd/kubeadm/app/cmd/alpha/BUILD b/cmd/kubeadm/app/cmd/alpha/BUILD index 5236e8c767e..fd2bbb4b577 100644 --- a/cmd/kubeadm/app/cmd/alpha/BUILD +++ b/cmd/kubeadm/app/cmd/alpha/BUILD @@ -21,6 +21,7 @@ go_library( "//cmd/kubeadm/app/cmd/util:go_default_library", "//cmd/kubeadm/app/constants:go_default_library", "//cmd/kubeadm/app/features:go_default_library", + "//cmd/kubeadm/app/phases/certs:go_default_library", "//cmd/kubeadm/app/phases/certs/renewal:go_default_library", "//cmd/kubeadm/app/phases/copycerts:go_default_library", "//cmd/kubeadm/app/phases/kubeconfig:go_default_library", @@ -34,6 +35,7 @@ go_library( "//vendor/github.com/lithammer/dedent:go_default_library", "//vendor/github.com/pkg/errors:go_default_library", "//vendor/github.com/spf13/cobra:go_default_library", + "//vendor/github.com/spf13/pflag:go_default_library", ], ) @@ -59,6 +61,8 @@ go_test( ], embed = [":go_default_library"], deps = [ + "//cmd/kubeadm/app/apis/kubeadm:go_default_library", + "//cmd/kubeadm/app/apis/kubeadm/v1beta2:go_default_library", "//cmd/kubeadm/app/constants:go_default_library", "//cmd/kubeadm/app/phases/certs:go_default_library", "//cmd/kubeadm/app/phases/kubeconfig:go_default_library", @@ -68,5 +72,8 @@ go_test( "//cmd/kubeadm/test/kubeconfig:go_default_library", "//staging/src/k8s.io/client-go/tools/clientcmd:go_default_library", "//vendor/github.com/spf13/cobra:go_default_library", + "//vendor/github.com/spf13/pflag:go_default_library", + "//vendor/github.com/stretchr/testify/assert:go_default_library", + "//vendor/github.com/stretchr/testify/require:go_default_library", ], ) diff --git a/cmd/kubeadm/app/cmd/alpha/certs.go b/cmd/kubeadm/app/cmd/alpha/certs.go index 57eb92072cc..ae62ffb91ad 100644 --- a/cmd/kubeadm/app/cmd/alpha/certs.go +++ b/cmd/kubeadm/app/cmd/alpha/certs.go @@ -24,6 +24,7 @@ import ( "github.com/lithammer/dedent" "github.com/pkg/errors" "github.com/spf13/cobra" + "github.com/spf13/pflag" "k8s.io/apimachinery/pkg/util/duration" kubeadmapi "k8s.io/kubernetes/cmd/kubeadm/app/apis/kubeadm" @@ -33,8 +34,10 @@ import ( cmdutil "k8s.io/kubernetes/cmd/kubeadm/app/cmd/util" "k8s.io/kubernetes/cmd/kubeadm/app/constants" kubeadmconstants "k8s.io/kubernetes/cmd/kubeadm/app/constants" + certsphase "k8s.io/kubernetes/cmd/kubeadm/app/phases/certs" "k8s.io/kubernetes/cmd/kubeadm/app/phases/certs/renewal" "k8s.io/kubernetes/cmd/kubeadm/app/phases/copycerts" + kubeconfigphase "k8s.io/kubernetes/cmd/kubeadm/app/phases/kubeconfig" configutil "k8s.io/kubernetes/cmd/kubeadm/app/util/config" kubeconfigutil "k8s.io/kubernetes/cmd/kubeadm/app/util/kubeconfig" ) @@ -68,6 +71,22 @@ var ( You can also use "kubeadm init --upload-certs" without specifying a certificate key and it will generate and print one for you. +`) + generateCSRLongDesc = cmdutil.LongDesc(` + Generates keys and certificate signing requests (CSRs) for all the certificates required to run the control plane. + This command also generates partial kubeconfig files with private key data in the "users > user > client-key-data" field, + and for each kubeconfig file an accompanying ".csr" file is created. + + This command is designed for use in [Kubeadm External CA Mode](https://kubernetes.io/docs/tasks/administer-cluster/kubeadm/kubeadm-certs/#external-ca-mode). + It generates CSRs which you can then submit to your external certificate authority for signing. + + The PEM encoded signed certificates should then be saved alongside the key files, using ".crt" as the file extension, + or in the case of kubeconfig files, the PEM encoded signed certificate should be base64 encoded + and added to the kubeconfig file in the "users > user > client-certificate-data" field. +`) + generateCSRExample = cmdutil.Examples(` + # The following command will generate keys and CSRs for all control-plane certificates and kubeconfig files: + kubeadm alpha certs generate-csr --kubeconfig-dir /tmp/etc-k8s --cert-dir /tmp/etc-k8s/pki `) ) @@ -82,9 +101,83 @@ func newCmdCertsUtility(out io.Writer) *cobra.Command { cmd.AddCommand(newCmdCertsRenewal(out)) cmd.AddCommand(newCmdCertsExpiration(out, constants.KubernetesDir)) cmd.AddCommand(NewCmdCertificateKey()) + cmd.AddCommand(newCmdGenCSR()) return cmd } +// genCSRConfig is the configuration required by the gencsr command +type genCSRConfig struct { + kubeadmConfigPath string + certDir string + kubeConfigDir string + kubeadmConfig *kubeadmapi.InitConfiguration +} + +func newGenCSRConfig() *genCSRConfig { + return &genCSRConfig{ + kubeConfigDir: kubeadmconstants.KubernetesDir, + } +} + +func (o *genCSRConfig) addFlagSet(flagSet *pflag.FlagSet) { + options.AddConfigFlag(flagSet, &o.kubeadmConfigPath) + options.AddCertificateDirFlag(flagSet, &o.certDir) + options.AddKubeConfigDirFlag(flagSet, &o.kubeConfigDir) +} + +// load merges command line flag values into kubeadm's config. +// Reads Kubeadm config from a file (if present) +// else use dynamically generated default config. +// This configuration contains the DNS names and IP addresses which +// are encoded in the control-plane CSRs. +func (o *genCSRConfig) load() (err error) { + o.kubeadmConfig, err = configutil.LoadOrDefaultInitConfiguration( + o.kubeadmConfigPath, + &kubeadmapiv1beta2.InitConfiguration{}, + &kubeadmapiv1beta2.ClusterConfiguration{}, + ) + if err != nil { + return err + } + // --cert-dir takes priority over kubeadm config if set. + if o.certDir != "" { + o.kubeadmConfig.CertificatesDir = o.certDir + } + return nil +} + +// newCmdGenCSR returns cobra.Command for generating keys and CSRs +func newCmdGenCSR() *cobra.Command { + config := newGenCSRConfig() + + cmd := &cobra.Command{ + Use: "generate-csr", + Short: "Generate keys and certificate signing requests", + Long: generateCSRLongDesc, + Example: generateCSRExample, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if err := config.load(); err != nil { + return err + } + return runGenCSR(config) + }, + } + config.addFlagSet(cmd.Flags()) + return cmd +} + +// runGenCSR contains the logic of the generate-csr sub-command. +func runGenCSR(config *genCSRConfig) error { + if err := certsphase.CreateDefaultKeysAndCSRFiles(config.kubeadmConfig); err != nil { + return err + } + if err := kubeconfigphase.CreateDefaultKubeConfigsAndCSRFiles(config.kubeConfigDir, config.kubeadmConfig); err != nil { + return err + } + return nil +} + // NewCmdCertificateKey returns cobra.Command for certificate key generate func NewCmdCertificateKey() *cobra.Command { return &cobra.Command{ diff --git a/cmd/kubeadm/app/cmd/alpha/certs_test.go b/cmd/kubeadm/app/cmd/alpha/certs_test.go index 67dc460bbec..2897cd0c40b 100644 --- a/cmd/kubeadm/app/cmd/alpha/certs_test.go +++ b/cmd/kubeadm/app/cmd/alpha/certs_test.go @@ -27,6 +27,13 @@ import ( "time" "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "k8s.io/client-go/tools/clientcmd" + kubeadmapi "k8s.io/kubernetes/cmd/kubeadm/app/apis/kubeadm" + kubeadmapiv1beta2 "k8s.io/kubernetes/cmd/kubeadm/app/apis/kubeadm/v1beta2" kubeadmconstants "k8s.io/kubernetes/cmd/kubeadm/app/constants" certsphase "k8s.io/kubernetes/cmd/kubeadm/app/phases/certs" kubeconfigphase "k8s.io/kubernetes/cmd/kubeadm/app/phases/kubeconfig" @@ -285,3 +292,185 @@ func TestRenewUsingCSR(t *testing.T) { t.Fatalf("couldn't load certificate %q: %v", cert.Name, err) } } + +func TestRunGenCSR(t *testing.T) { + tmpDir := testutil.SetupTempDir(t) + defer os.RemoveAll(tmpDir) + + kubeConfigDir := filepath.Join(tmpDir, "kubernetes") + certDir := kubeConfigDir + "/pki" + + expectedCertificates := []string{ + "apiserver", + "apiserver-etcd-client", + "apiserver-kubelet-client", + "front-proxy-client", + "etcd/healthcheck-client", + "etcd/peer", + "etcd/server", + } + + expectedKubeConfigs := []string{ + "admin", + "kubelet", + "controller-manager", + "scheduler", + } + + config := genCSRConfig{ + kubeConfigDir: kubeConfigDir, + kubeadmConfig: &kubeadmapi.InitConfiguration{ + LocalAPIEndpoint: kubeadmapi.APIEndpoint{ + AdvertiseAddress: "192.0.2.1", + BindPort: 443, + }, + ClusterConfiguration: kubeadmapi.ClusterConfiguration{ + Networking: kubeadmapi.Networking{ + ServiceSubnet: "192.0.2.0/24", + }, + CertificatesDir: certDir, + KubernetesVersion: "v1.19.0", + }, + }, + } + + err := runGenCSR(&config) + require.NoError(t, err, "expected runGenCSR to not fail") + + t.Log("The command generates key and CSR files in the configured --cert-dir") + for _, name := range expectedCertificates { + _, err = pkiutil.TryLoadKeyFromDisk(certDir, name) + assert.NoErrorf(t, err, "failed to load key file: %s", name) + + _, err = pkiutil.TryLoadCSRFromDisk(certDir, name) + assert.NoError(t, err, "failed to load CSR file: %s", name) + } + + t.Log("The command generates kubeconfig files in the configured --kubeconfig-dir") + for _, name := range expectedKubeConfigs { + _, err = clientcmd.LoadFromFile(kubeConfigDir + "/" + name + ".conf") + assert.NoErrorf(t, err, "failed to load kubeconfig file: %s", name) + + _, err = pkiutil.TryLoadCSRFromDisk(kubeConfigDir, name+".conf") + assert.NoError(t, err, "failed to load kubeconfig CSR file: %s", name) + } +} + +func TestGenCSRConfig(t *testing.T) { + type assertion func(*testing.T, *genCSRConfig) + + hasCertDir := func(expected string) assertion { + return func(t *testing.T, config *genCSRConfig) { + assert.Equal(t, expected, config.kubeadmConfig.CertificatesDir) + } + } + hasKubeConfigDir := func(expected string) assertion { + return func(t *testing.T, config *genCSRConfig) { + assert.Equal(t, expected, config.kubeConfigDir) + } + } + hasAdvertiseAddress := func(expected string) assertion { + return func(t *testing.T, config *genCSRConfig) { + assert.Equal(t, expected, config.kubeadmConfig.LocalAPIEndpoint.AdvertiseAddress) + } + } + + // A minimal kubeadm config with just enough values to avoid triggering + // auto-detection of config values at runtime. + const kubeadmConfig = ` +apiVersion: kubeadm.k8s.io/v1beta2 +kind: InitConfiguration +localAPIEndpoint: + advertiseAddress: 192.0.2.1 +nodeRegistration: + criSocket: /path/to/dockershim.sock +--- +apiVersion: kubeadm.k8s.io/v1beta2 +kind: ClusterConfiguration +certificatesDir: /custom/config/certificates-dir +kubernetesVersion: v1.19.0 +` + + tmpDir := testutil.SetupTempDir(t) + defer os.RemoveAll(tmpDir) + + customConfigPath := tmpDir + "/kubeadm.conf" + + f, err := os.Create(customConfigPath) + require.NoError(t, err) + _, err = f.Write([]byte(kubeadmConfig)) + require.NoError(t, err) + + tests := []struct { + name string + flags []string + assertions []assertion + expectErr bool + }{ + { + name: "default", + assertions: []assertion{ + hasCertDir(kubeadmapiv1beta2.DefaultCertificatesDir), + hasKubeConfigDir(kubeadmconstants.KubernetesDir), + }, + }, + { + name: "--cert-dir overrides default", + flags: []string{"--cert-dir", "/foo/bar/pki"}, + assertions: []assertion{ + hasCertDir("/foo/bar/pki"), + }, + }, + { + name: "--config is loaded", + flags: []string{"--config", customConfigPath}, + assertions: []assertion{ + hasCertDir("/custom/config/certificates-dir"), + hasAdvertiseAddress("192.0.2.1"), + }, + }, + { + name: "--config not found", + flags: []string{"--config", "/does/not/exist"}, + expectErr: true, + }, + { + name: "--cert-dir overrides --config certificatesDir", + flags: []string{ + "--config", customConfigPath, + "--cert-dir", "/foo/bar/pki", + }, + assertions: []assertion{ + hasCertDir("/foo/bar/pki"), + hasAdvertiseAddress("192.0.2.1"), + }, + }, + { + name: "--kubeconfig-dir overrides default", + flags: []string{ + "--kubeconfig-dir", "/foo/bar/kubernetes", + }, + assertions: []assertion{ + hasKubeConfigDir("/foo/bar/kubernetes"), + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + flagset := pflag.NewFlagSet("flags-for-gencsr", pflag.ContinueOnError) + config := newGenCSRConfig() + config.addFlagSet(flagset) + require.NoError(t, flagset.Parse(test.flags)) + + err := config.load() + if test.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + for _, assertFunc := range test.assertions { + assertFunc(t, config) + } + }) + } +}