diff --git a/Makefile b/Makefile index 67e6c1279..6cb852913 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ HELM_TGZ ?= $(LOCALBIN)/helm.tar.gz HELM ?= $(LOCALBIN)/helm ## Tool Versions -CONTROLLER_TOOLS_VERSION ?= v0.15.0 +CONTROLLER_TOOLS_VERSION ?= v0.20.0 .PHONY: fmt fmt: ## Run go fmt against code. diff --git a/pkg/cmd/get/certificate.go b/pkg/cmd/get/certificate.go new file mode 100644 index 000000000..e18e5e11c --- /dev/null +++ b/pkg/cmd/get/certificate.go @@ -0,0 +1,179 @@ +package get + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/cnoe-io/idpbuilder/globals" + "github.com/cnoe-io/idpbuilder/pkg/util" + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var ( + certOutputPath string + setupDocker bool +) + +var CertificateCmd = &cobra.Command{ + Use: "certificate", + Short: "Export the TLS certificate from the cluster", + Long: `Export the self-signed TLS certificate from the cluster. + +By default, the certificate is printed to stdout. Use the --docker flag to automatically +configure Docker's per-registry certificate directory, which allows Docker to trust the +Gitea container registry without requiring a Docker daemon restart. + +Examples: + # Print certificate to stdout + idpbuilder get certificate + + # Export to Docker's registry certificate directory (no Docker restart needed) + idpbuilder get certificate --docker + + # Export to a custom file + idpbuilder get certificate --output ~/my-cert.crt`, + RunE: getCertificateE, + SilenceUsage: true, +} + +func init() { + CertificateCmd.Flags().StringVarP(&certOutputPath, "output", "o", "", "Custom output path for the certificate file") + CertificateCmd.Flags().BoolVar(&setupDocker, "docker", false, "Setup Docker registry certificate directory") +} + +func getCertificateE(cmd *cobra.Command, args []string) error { + ctx, ctxCancel := context.WithCancel(cmd.Context()) + defer ctxCancel() + + kubeConfig, err := util.GetKubeConfig() + if err != nil { + return fmt.Errorf("getting kube config: %w", err) + } + + kubeClient, err := util.GetKubeClient(kubeConfig) + if err != nil { + return fmt.Errorf("getting kube client: %w", err) + } + + // Get the certificate from the cluster + cert, err := getCertificateFromCluster(ctx, kubeClient) + if err != nil { + return err + } + + // Get the build configuration to determine registry host + config, err := util.GetConfig(ctx) + if err != nil { + return fmt.Errorf("getting idp config: %w", err) + } + + var registryHost string + if config.UsePathRouting { + registryHost = fmt.Sprintf("%s:%s", config.Host, config.Port) + } else { + registryHost = fmt.Sprintf("gitea.%s:%s", config.Host, config.Port) + } + + // Determine output behavior + if certOutputPath != "" { + // Custom output path specified + if err := os.WriteFile(certOutputPath, cert, 0644); err != nil { + return fmt.Errorf("writing certificate to %s: %w", certOutputPath, err) + } + fmt.Printf("Certificate exported to: %s\n", certOutputPath) + return nil + } else if setupDocker { + // Docker's per-registry certificate directory + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("getting user home directory: %w", err) + } + + dockerCertsDir := filepath.Join(homeDir, ".docker", "certs.d", registryHost) + if err := os.MkdirAll(dockerCertsDir, 0755); err != nil { + return fmt.Errorf("creating Docker certificate directory: %w", err) + } + + outputPath := filepath.Join(dockerCertsDir, "ca.crt") + if err := os.WriteFile(outputPath, cert, 0644); err != nil { + return fmt.Errorf("writing certificate to %s: %w", outputPath, err) + } + + fmt.Printf("Certificate exported successfully to: %s\n", outputPath) + fmt.Printf("Registry host: %s\n\n", registryHost) + printPostInstallInstructions(registryHost) + return nil + } + + // Default: print to stdout + fmt.Print(string(cert)) + return nil +} + +func getCertificateFromCluster(ctx context.Context, kubeClient client.Client) ([]byte, error) { + secret := &corev1.Secret{} + secretKey := client.ObjectKey{ + Name: globals.SelfSignedCertSecretName, + Namespace: globals.NginxNamespace, + } + + if err := kubeClient.Get(ctx, secretKey, secret); err != nil { + return nil, fmt.Errorf("getting certificate secret from cluster: %w. Make sure the cluster is running.", err) + } + + cert, ok := secret.Data[corev1.TLSCertKey] + if !ok { + return nil, fmt.Errorf("certificate not found in secret %s/%s", globals.NginxNamespace, globals.SelfSignedCertSecretName) + } + + return cert, nil +} + +func printPostInstallInstructions(registryHost string) { + fmt.Println("Next steps:") + fmt.Println(" 1. The certificate has been configured for Docker's per-registry trust") + fmt.Println(" 2. No Docker restart is required - the certificate should work immediately") + fmt.Println() + fmt.Println("To verify Docker can access the registry:") + fmt.Printf(" docker pull %s/test-image\n", registryHost) + fmt.Println() + + // Platform-specific instructions for system-wide trust (optional) + switch runtime.GOOS { + case "darwin": + fmt.Println("Optional: To trust this certificate system-wide (for browsers, curl, etc.):") + fmt.Println(" 1. Find the certificate file:") + homeDir, _ := os.UserHomeDir() + fmt.Printf(" %s\n", filepath.Join(homeDir, ".docker", "certs.d", registryHost, "ca.crt")) + fmt.Println(" 2. Double-click the certificate file to open Keychain Access") + fmt.Println(" 3. Select 'System' keychain and click 'Add'") + fmt.Println(" 4. Double-click the imported certificate") + fmt.Println(" 5. Expand 'Trust' section and set 'When using this certificate' to 'Always Trust'") + fmt.Println() + fmt.Println(" Or use the command line:") + certPath := filepath.Join(homeDir, ".docker", "certs.d", registryHost, "ca.crt") + fmt.Printf(" sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain %s\n", certPath) + + case "linux": + fmt.Println("Optional: To trust this certificate system-wide (for browsers, curl, etc.):") + homeDir, _ := os.UserHomeDir() + certPath := filepath.Join(homeDir, ".docker", "certs.d", registryHost, "ca.crt") + fmt.Printf(" sudo cp %s /usr/local/share/ca-certificates/idpbuilder-ca.crt\n", certPath) + fmt.Println(" sudo update-ca-certificates") + + case "windows": + fmt.Println("Optional: To trust this certificate system-wide:") + fmt.Println(" 1. Open the certificate file in Explorer") + fmt.Println(" 2. Click 'Install Certificate'") + fmt.Println(" 3. Select 'Local Machine' and click Next") + fmt.Println(" 4. Select 'Place all certificates in the following store'") + fmt.Println(" 5. Browse to 'Trusted Root Certification Authorities'") + fmt.Println(" 6. Click OK and Finish") + } + fmt.Println() +} diff --git a/pkg/cmd/get/certificate_test.go b/pkg/cmd/get/certificate_test.go new file mode 100644 index 000000000..411cff9b4 --- /dev/null +++ b/pkg/cmd/get/certificate_test.go @@ -0,0 +1,219 @@ +package get + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/cnoe-io/idpbuilder/api/v1alpha1" + "github.com/cnoe-io/idpbuilder/globals" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func TestGetCertificateFromCluster(t *testing.T) { + ctx := context.Background() + + t.Run("successful certificate retrieval", func(t *testing.T) { + fClient := new(fakeKubeClient) + expectedCert := []byte("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----") + + secretKey := client.ObjectKey{ + Name: globals.SelfSignedCertSecretName, + Namespace: globals.NginxNamespace, + } + + fClient.On("Get", ctx, secretKey, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + arg := args.Get(2).(*corev1.Secret) + arg.Data = map[string][]byte{ + corev1.TLSCertKey: expectedCert, + } + }).Return(nil) + + cert, err := getCertificateFromCluster(ctx, fClient) + assert.NoError(t, err) + assert.Equal(t, expectedCert, cert) + fClient.AssertExpectations(t) + }) + + t.Run("secret not found", func(t *testing.T) { + fClient := new(fakeKubeClient) + + secretKey := client.ObjectKey{ + Name: globals.SelfSignedCertSecretName, + Namespace: globals.NginxNamespace, + } + + notFoundErr := errors.NewNotFound(schema.GroupResource{Resource: "secrets"}, globals.SelfSignedCertSecretName) + fClient.On("Get", ctx, secretKey, mock.Anything, mock.Anything).Return(notFoundErr) + + cert, err := getCertificateFromCluster(ctx, fClient) + assert.Error(t, err) + assert.Nil(t, cert) + assert.Contains(t, err.Error(), "getting certificate secret from cluster") + fClient.AssertExpectations(t) + }) + + t.Run("certificate data missing from secret", func(t *testing.T) { + fClient := new(fakeKubeClient) + + secretKey := client.ObjectKey{ + Name: globals.SelfSignedCertSecretName, + Namespace: globals.NginxNamespace, + } + + fClient.On("Get", ctx, secretKey, mock.Anything, mock.Anything).Run(func(args mock.Arguments) { + arg := args.Get(2).(*corev1.Secret) + arg.Data = map[string][]byte{ + // Missing TLSCertKey + "other-key": []byte("value"), + } + }).Return(nil) + + cert, err := getCertificateFromCluster(ctx, fClient) + assert.Error(t, err) + assert.Nil(t, cert) + assert.Contains(t, err.Error(), "certificate not found in secret") + fClient.AssertExpectations(t) + }) +} + +func TestRegistryHostDetermination(t *testing.T) { + testCases := []struct { + name string + config v1alpha1.BuildCustomizationSpec + expectedHost string + }{ + { + name: "subdomain routing", + config: v1alpha1.BuildCustomizationSpec{ + Host: "cnoe.localtest.me", + Port: "8443", + UsePathRouting: false, + }, + expectedHost: "gitea.cnoe.localtest.me:8443", + }, + { + name: "path routing", + config: v1alpha1.BuildCustomizationSpec{ + Host: "cnoe.localtest.me", + Port: "8443", + UsePathRouting: true, + }, + expectedHost: "cnoe.localtest.me:8443", + }, + { + name: "custom host with path routing", + config: v1alpha1.BuildCustomizationSpec{ + Host: "example.com", + Port: "443", + UsePathRouting: true, + }, + expectedHost: "example.com:443", + }, + { + name: "custom host with subdomain routing", + config: v1alpha1.BuildCustomizationSpec{ + Host: "example.com", + Port: "443", + UsePathRouting: false, + }, + expectedHost: "gitea.example.com:443", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var registryHost string + if tc.config.UsePathRouting { + registryHost = tc.config.Host + ":" + tc.config.Port + } else { + registryHost = "gitea." + tc.config.Host + ":" + tc.config.Port + } + assert.Equal(t, tc.expectedHost, registryHost) + }) + } +} + +func TestCertificateFileOperations(t *testing.T) { + t.Run("write certificate to custom path", func(t *testing.T) { + tempDir := t.TempDir() + certPath := filepath.Join(tempDir, "test-cert.crt") + testCert := []byte("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----") + + err := os.WriteFile(certPath, testCert, 0644) + assert.NoError(t, err) + + // Verify file was created and contains correct data + readCert, err := os.ReadFile(certPath) + assert.NoError(t, err) + assert.Equal(t, testCert, readCert) + + // Verify file permissions + info, err := os.Stat(certPath) + assert.NoError(t, err) + assert.Equal(t, os.FileMode(0644), info.Mode().Perm()) + }) + + t.Run("create docker certs.d directory structure", func(t *testing.T) { + tempDir := t.TempDir() + registryHost := "gitea.cnoe.localtest.me:8443" + dockerCertsDir := filepath.Join(tempDir, ".docker", "certs.d", registryHost) + + err := os.MkdirAll(dockerCertsDir, 0755) + assert.NoError(t, err) + + // Verify directory was created + info, err := os.Stat(dockerCertsDir) + assert.NoError(t, err) + assert.True(t, info.IsDir()) + + // Verify we can write a certificate to it + certPath := filepath.Join(dockerCertsDir, "ca.crt") + testCert := []byte("test certificate") + err = os.WriteFile(certPath, testCert, 0644) + assert.NoError(t, err) + + readCert, err := os.ReadFile(certPath) + assert.NoError(t, err) + assert.Equal(t, testCert, readCert) + }) + + t.Run("handle directory creation errors", func(t *testing.T) { + // Try to create a directory in a non-existent parent that we can't create + invalidPath := filepath.Join("/nonexistent-root-12345", "subdir") + err := os.MkdirAll(invalidPath, 0755) + assert.Error(t, err) + }) +} + +func TestPrintPostInstallInstructions(t *testing.T) { + // This is a smoke test to ensure the function doesn't panic + // We don't assert on the output as it's informational + t.Run("print instructions without panic", func(t *testing.T) { + assert.NotPanics(t, func() { + printPostInstallInstructions("gitea.cnoe.localtest.me:8443") + }) + }) +} + +func TestCertificateCommandFlags(t *testing.T) { + t.Run("certificate command has required flags", func(t *testing.T) { + assert.NotNil(t, CertificateCmd) + assert.Equal(t, "certificate", CertificateCmd.Use) + + // Check that flags are defined + outputFlag := CertificateCmd.Flags().Lookup("output") + assert.NotNil(t, outputFlag) + assert.Equal(t, "o", outputFlag.Shorthand) + + dockerFlag := CertificateCmd.Flags().Lookup("docker") + assert.NotNil(t, dockerFlag) + assert.Equal(t, "false", dockerFlag.DefValue) + }) +} diff --git a/pkg/cmd/get/root.go b/pkg/cmd/get/root.go index 128985173..612f2f7bd 100644 --- a/pkg/cmd/get/root.go +++ b/pkg/cmd/get/root.go @@ -22,6 +22,7 @@ func init() { GetCmd.AddCommand(ClustersCmd) GetCmd.AddCommand(SecretsCmd) GetCmd.AddCommand(PackagesCmd) + GetCmd.AddCommand(CertificateCmd) GetCmd.PersistentFlags().StringSliceVarP(&packages, "packages", "p", []string{}, "names of packages.") GetCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "table", "Output format: table (default if not specified), json or yaml.") GetCmd.PersistentFlags().StringVarP(&util.KubeConfigPath, "kubeconfig", "", "", "kube config file Path.") diff --git a/pkg/controllers/resources/idpbuilder.cnoe.io_custompackages.yaml b/pkg/controllers/resources/idpbuilder.cnoe.io_custompackages.yaml index a81df9ad8..805eb2c9f 100644 --- a/pkg/controllers/resources/idpbuilder.cnoe.io_custompackages.yaml +++ b/pkg/controllers/resources/idpbuilder.cnoe.io_custompackages.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.15.0 + controller-gen.kubebuilder.io/version: v0.20.0 name: custompackages.idpbuilder.cnoe.io spec: group: idpbuilder.cnoe.io diff --git a/pkg/controllers/resources/idpbuilder.cnoe.io_gitrepositories.yaml b/pkg/controllers/resources/idpbuilder.cnoe.io_gitrepositories.yaml index e305cdcf3..ed17fbf42 100644 --- a/pkg/controllers/resources/idpbuilder.cnoe.io_gitrepositories.yaml +++ b/pkg/controllers/resources/idpbuilder.cnoe.io_gitrepositories.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.15.0 + controller-gen.kubebuilder.io/version: v0.20.0 name: gitrepositories.idpbuilder.cnoe.io spec: group: idpbuilder.cnoe.io diff --git a/pkg/controllers/resources/idpbuilder.cnoe.io_localbuilds.yaml b/pkg/controllers/resources/idpbuilder.cnoe.io_localbuilds.yaml index d1959ab7c..e9bfcc9af 100644 --- a/pkg/controllers/resources/idpbuilder.cnoe.io_localbuilds.yaml +++ b/pkg/controllers/resources/idpbuilder.cnoe.io_localbuilds.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.15.0 + controller-gen.kubebuilder.io/version: v0.20.0 name: localbuilds.idpbuilder.cnoe.io spec: group: idpbuilder.cnoe.io