From 4d89128511c07f0031b38eb0ba67ea4a7832c325 Mon Sep 17 00:00:00 2001 From: Maksim Kiselev Date: Wed, 27 May 2026 18:28:09 +0300 Subject: [PATCH] Added stronghold auto-discovery from kube API Signed-off-by: Maksim Kiselev --- cmd/commands/stronghold.go | 9 +- cmd/commands/stronghold_commands.go | 4 +- internal/strongholddiscovery/discovery.go | 257 ++++++++++++++ .../strongholddiscovery/discovery_test.go | 323 ++++++++++++++++++ 4 files changed, 590 insertions(+), 3 deletions(-) create mode 100644 internal/strongholddiscovery/discovery.go create mode 100644 internal/strongholddiscovery/discovery_test.go diff --git a/cmd/commands/stronghold.go b/cmd/commands/stronghold.go index 9b668a3f..16ad4060 100644 --- a/cmd/commands/stronghold.go +++ b/cmd/commands/stronghold.go @@ -19,6 +19,8 @@ package commands import ( vaultcommand "github.com/hashicorp/vault/command" "github.com/spf13/cobra" + + "github.com/deckhouse/deckhouse-cli/internal/strongholddiscovery" ) func NewStrongholdCommand() *cobra.Command { @@ -29,7 +31,7 @@ func NewStrongholdCommand() *cobra.Command { SilenceUsage: true, DisableFlagParsing: true, Run: func(_ *cobra.Command, args []string) { - vaultcommand.Run(args) + runStronghold(args) }, } @@ -39,3 +41,8 @@ func NewStrongholdCommand() *cobra.Command { return strongholdCmd } + +func runStronghold(args []string) { + strongholddiscovery.ApplyFromCluster() + vaultcommand.Run(args) +} diff --git a/cmd/commands/stronghold_commands.go b/cmd/commands/stronghold_commands.go index 69f9f63f..eb5f763c 100644 --- a/cmd/commands/stronghold_commands.go +++ b/cmd/commands/stronghold_commands.go @@ -96,7 +96,7 @@ func buildCommandTree(synopses map[string]string) map[string]*commandNode { } // buildCobraCommands recursively converts a commandNode tree into cobra commands. -// Each command delegates execution to vaultcommand.Run with the appropriate +// Each command delegates execution to runStronghold with the appropriate // command path prefix, preserving the original vault CLI behavior. func buildCobraCommands(nodes map[string]*commandNode, pathPrefix []string) []*cobra.Command { keys := sortedCommandKeys(nodes) @@ -117,7 +117,7 @@ func buildCobraCommands(nodes map[string]*commandNode, pathPrefix []string) []*c fullArgs := make([]string, 0, len(path)+len(args)) fullArgs = append(fullArgs, path...) fullArgs = append(fullArgs, args...) - vaultcommand.Run(fullArgs) + runStronghold(fullArgs) } }(vaultPath), } diff --git a/internal/strongholddiscovery/discovery.go b/internal/strongholddiscovery/discovery.go new file mode 100644 index 00000000..08678af3 --- /dev/null +++ b/internal/strongholddiscovery/discovery.go @@ -0,0 +1,257 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package strongholddiscovery + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/kubernetes" +) + +const ( + Namespace = "d8-stronghold" + IngressName = "stronghold" + KeysSecretName = "stronghold-keys" + IngressTLSSecret = "ingress-tls" + IngressTLSCertKey = "tls.crt" + RootTokenKey = "rootToken" + AddrEnv = "STRONGHOLD_ADDR" + TokenEnv = "STRONGHOLD_TOKEN" + CABytesEnv = "STRONGHOLD_CACERT_BYTES" + VaultAddrEnv = "VAULT_ADDR" + VaultTokenEnv = "VAULT_TOKEN" + VaultCABytesEnv = "VAULT_CACERT_BYTES" + VaultCAEnv = "VAULT_CACERT" + VaultCAPathEnv = "VAULT_CAPATH" + VaultSkipVerifyEnv = "VAULT_SKIP_VERIFY" + StrongholdCAEnv = "STRONGHOLD_CACERT" + StrongholdCAPathEnv = "STRONGHOLD_CAPATH" + StrongholdSkipVerify = "STRONGHOLD_SKIP_VERIFY" + VaultTokenFileName = ".vault-token" +) + +func addrConfigured() bool { + return os.Getenv(VaultAddrEnv) != "" || os.Getenv(AddrEnv) != "" +} + +func tokenConfigured() bool { + if os.Getenv(VaultTokenEnv) != "" || os.Getenv(TokenEnv) != "" { + return true + } + + return vaultTokenFileExists() +} + +func caConfigured() bool { + return os.Getenv(VaultCABytesEnv) != "" || + os.Getenv(VaultCAEnv) != "" || + os.Getenv(VaultCAPathEnv) != "" || + os.Getenv(VaultSkipVerifyEnv) != "" || + os.Getenv(CABytesEnv) != "" || + os.Getenv(StrongholdCAEnv) != "" || + os.Getenv(StrongholdCAPathEnv) != "" || + os.Getenv(StrongholdSkipVerify) != "" +} + +func vaultTokenFilePath() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + return filepath.Join(home, VaultTokenFileName) +} + +func vaultTokenFileExists() bool { + path := vaultTokenFilePath() + if path == "" { + return false + } + + _, err := os.Stat(path) + + return err == nil +} + +// ApplyFromCluster sets STRONGHOLD_ADDR, STRONGHOLD_TOKEN and STRONGHOLD_CACERT_BYTES +// from the cluster when they are not already configured. Failures are ignored silently. +func ApplyFromCluster() { + needAddr := !addrConfigured() + needToken := !tokenConfigured() + + needCA := !caConfigured() + if !needAddr && !needToken && !needCA { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + kubeCl, err := kubeClient() + if err != nil { + return + } + + if needAddr { + if addr, err := discoverAddr(ctx, kubeCl); err == nil { + _ = os.Setenv(AddrEnv, addr) + } + } + + if needToken { + if token, err := discoverToken(ctx, kubeCl); err == nil { + _ = os.Setenv(TokenEnv, token) + } + } + + if needCA { + if ca, err := discoverCA(ctx, kubeCl); err == nil { + _ = os.Setenv(CABytesEnv, ca) + } + } +} + +func kubeClient() (kubernetes.Interface, error) { + configFlags := genericclioptions.NewConfigFlags(true) + + restConfig, err := configFlags.ToRESTConfig() + if err != nil { + return nil, err + } + + return kubernetes.NewForConfig(restConfig) +} + +func discoverAddr(ctx context.Context, kubeCl kubernetes.Interface) (string, error) { + ingress, err := kubeCl.NetworkingV1().Ingresses(Namespace).Get( + ctx, + IngressName, + metav1.GetOptions{}, + ) + if err != nil { + return "", err + } + + addr, ok := AddrFromIngress(ingress) + if !ok { + return "", fmt.Errorf("ingress %s/%s has no host", Namespace, IngressName) + } + + return addr, nil +} + +func discoverToken(ctx context.Context, kubeCl kubernetes.Interface) (string, error) { + secret, err := kubeCl.CoreV1().Secrets(Namespace).Get( + ctx, + KeysSecretName, + metav1.GetOptions{}, + ) + if err != nil { + return "", err + } + + return TokenFromSecret(secret) +} + +func discoverCA(ctx context.Context, kubeCl kubernetes.Interface) (string, error) { + secret, err := kubeCl.CoreV1().Secrets(Namespace).Get( + ctx, + IngressTLSSecret, + metav1.GetOptions{}, + ) + if err != nil { + return "", err + } + + return CAFromIngressTLSSecret(secret) +} + +// CAFromIngressTLSSecret extracts the ingress TLS certificate from secret data. +// Kubernetes stores tls.crt base64-encoded; client-go returns decoded PEM bytes. +func CAFromIngressTLSSecret(secret *corev1.Secret) (string, error) { + if secret == nil { + return "", fmt.Errorf("secret is nil") + } + + certBytes, ok := secret.Data[IngressTLSCertKey] + if !ok || len(certBytes) == 0 { + return "", fmt.Errorf("secret %s/%s has no %s", Namespace, IngressTLSSecret, IngressTLSCertKey) + } + + cert := string(certBytes) + if cert == "" { + return "", fmt.Errorf("secret %s/%s has empty %s", Namespace, IngressTLSSecret, IngressTLSCertKey) + } + + return cert, nil +} + +// TokenFromSecret extracts the root token from the stronghold-keys secret. +func TokenFromSecret(secret *corev1.Secret) (string, error) { + if secret == nil { + return "", fmt.Errorf("secret is nil") + } + + tokenBytes, ok := secret.Data[RootTokenKey] + if !ok || len(tokenBytes) == 0 { + return "", fmt.Errorf("secret %s/%s has no %s", Namespace, KeysSecretName, RootTokenKey) + } + + token := string(tokenBytes) + if token == "" { + return "", fmt.Errorf("secret %s/%s has empty %s", Namespace, KeysSecretName, RootTokenKey) + } + + return token, nil +} + +// AddrFromIngress builds a Stronghold API URL from an Ingress resource. +func AddrFromIngress(ingress *networkingv1.Ingress) (string, bool) { + if ingress == nil { + return "", false + } + + host := ingressHost(ingress) + if host == "" { + return "", false + } + + scheme := "http" + if len(ingress.Spec.TLS) > 0 { + scheme = "https" + } + + return fmt.Sprintf("%s://%s", scheme, host), true +} + +func ingressHost(ingress *networkingv1.Ingress) string { + for _, rule := range ingress.Spec.Rules { + if rule.Host != "" { + return rule.Host + } + } + + return "" +} diff --git a/internal/strongholddiscovery/discovery_test.go b/internal/strongholddiscovery/discovery_test.go new file mode 100644 index 00000000..a0d61bce --- /dev/null +++ b/internal/strongholddiscovery/discovery_test.go @@ -0,0 +1,323 @@ +/* +Copyright 2024 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package strongholddiscovery + +import ( + "os" + "path/filepath" + "testing" + + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestAddrConfigured(t *testing.T) { + tests := []struct { + name string + vaultAddr string + shAddr string + want bool + }{ + {name: "none", want: false}, + {name: "vault only", vaultAddr: "https://vault.example.com", want: true}, + {name: "stronghold only", shAddr: "https://stronghold.example.com", want: true}, + { + name: "both", + vaultAddr: "https://vault.example.com", + shAddr: "https://stronghold.example.com", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(VaultAddrEnv, tt.vaultAddr) + t.Setenv(AddrEnv, tt.shAddr) + + if got := addrConfigured(); got != tt.want { + t.Fatalf("addrConfigured() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestStrongholdAddrFromIngress(t *testing.T) { + tests := []struct { + name string + ingress *networkingv1.Ingress + want string + ok bool + }{ + { + name: "nil ingress", + ingress: nil, + ok: false, + }, + { + name: "no rules", + ingress: &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{Name: IngressName}, + }, + ok: false, + }, + { + name: "http host", + ingress: &networkingv1.Ingress{ + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + {Host: "stronghold.example.com"}, + }, + }, + }, + want: "http://stronghold.example.com", + ok: true, + }, + { + name: "https with tls", + ingress: &networkingv1.Ingress{ + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + {Host: "stronghold.stronghold-demo.test.dev"}, + }, + TLS: []networkingv1.IngressTLS{ + {Hosts: []string{"stronghold.stronghold-demo.test.dev"}}, + }, + }, + }, + want: "https://stronghold.stronghold-demo.test.dev", + ok: true, + }, + { + name: "first non-empty host", + ingress: &networkingv1.Ingress{ + Spec: networkingv1.IngressSpec{ + Rules: []networkingv1.IngressRule{ + {Host: ""}, + {Host: "stronghold.example.com"}, + }, + TLS: []networkingv1.IngressTLS{{}}, + }, + }, + want: "https://stronghold.example.com", + ok: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := AddrFromIngress(tt.ingress) + if ok != tt.ok { + t.Fatalf("ok = %v, want %v", ok, tt.ok) + } + if got != tt.want { + t.Fatalf("addr = %q, want %q", got, tt.want) + } + }) + } +} + +func TestTokenConfigured(t *testing.T) { + tests := []struct { + name string + vaultToken string + shToken string + tokenFile bool + want bool + }{ + {name: "none", want: false}, + {name: "vault token env", vaultToken: "hvs.vault", want: true}, + {name: "stronghold token env", shToken: "hvs.stronghold", want: true}, + {name: "vault token file", tokenFile: true, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(VaultTokenEnv, tt.vaultToken) + t.Setenv(TokenEnv, tt.shToken) + + if tt.tokenFile { + dir := t.TempDir() + t.Setenv("HOME", dir) + if err := os.WriteFile(filepath.Join(dir, VaultTokenFileName), []byte("token"), 0o600); err != nil { + t.Fatalf("write vault token file: %v", err) + } + } + + if got := tokenConfigured(); got != tt.want { + t.Fatalf("tokenConfigured() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTokenFromSecret(t *testing.T) { + tests := []struct { + name string + secret *corev1.Secret + want string + wantErr bool + }{ + { + name: "nil secret", + secret: nil, + wantErr: true, + }, + { + name: "missing rootToken", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: KeysSecretName}, + Data: map[string][]byte{}, + }, + wantErr: true, + }, + { + name: "root token", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: KeysSecretName}, + Data: map[string][]byte{ + RootTokenKey: []byte("s.23dKWH3vTnJVT7xsxxNuBdnN"), + }, + }, + want: "s.23dKWH3vTnJVT7xsxxNuBdnN", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := TokenFromSecret(tt.secret) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("token = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCAConfigured(t *testing.T) { + envs := []string{ + VaultCABytesEnv, + VaultCAEnv, + VaultCAPathEnv, + VaultSkipVerifyEnv, + CABytesEnv, + StrongholdCAEnv, + StrongholdCAPathEnv, + StrongholdSkipVerify, + } + + for _, env := range envs { + t.Run(env, func(t *testing.T) { + for _, other := range envs { + t.Setenv(other, "") + } + t.Setenv(env, "configured") + + if got := caConfigured(); !got { + t.Fatalf("caConfigured() = false, want true when %s is set", env) + } + }) + } + + t.Run("none", func(t *testing.T) { + for _, env := range envs { + t.Setenv(env, "") + } + if got := caConfigured(); got { + t.Fatalf("caConfigured() = true, want false") + } + }) +} + +func TestCAFromIngressTLSSecret(t *testing.T) { + pemCert := "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n" + + tests := []struct { + name string + secret *corev1.Secret + want string + wantErr bool + }{ + { + name: "nil secret", + secret: nil, + wantErr: true, + }, + { + name: "missing tls.crt", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: IngressTLSSecret}, + Data: map[string][]byte{}, + }, + wantErr: true, + }, + { + name: "tls.crt", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: IngressTLSSecret}, + Data: map[string][]byte{ + IngressTLSCertKey: []byte(pemCert), + }, + }, + want: pemCert, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := CAFromIngressTLSSecret(tt.secret) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("cert = %q, want %q", got, tt.want) + } + }) + } +} + +func TestApplyFromCluster_SkipsWhenConfigured(t *testing.T) { + t.Setenv(AddrEnv, "https://configured.example.com") + t.Setenv(TokenEnv, "hvs.configured") + t.Setenv(CABytesEnv, "-----BEGIN CERTIFICATE-----\nconfigured\n-----END CERTIFICATE-----\n") + + ApplyFromCluster() + + if got := os.Getenv(AddrEnv); got != "https://configured.example.com" { + t.Fatalf("STRONGHOLD_ADDR = %q, want unchanged configured value", got) + } + if got := os.Getenv(TokenEnv); got != "hvs.configured" { + t.Fatalf("STRONGHOLD_TOKEN = %q, want unchanged configured value", got) + } + if got := os.Getenv(CABytesEnv); got != "-----BEGIN CERTIFICATE-----\nconfigured\n-----END CERTIFICATE-----\n" { + t.Fatalf("STRONGHOLD_CACERT_BYTES = %q, want unchanged configured value", got) + } +}