blob: b75381b841eb1b5ba676db532fb539892df5b5e1 [file]
package cmd
import (
"encoding/base64"
"fmt"
"net/url"
"os"
"strings"
"github.com/apache/dubbo-kubernetes/pkg/kube/inject"
"github.com/apache/dubbo-kubernetes/pkg/kube/multicluster"
"github.com/spf13/cobra"
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
"sigs.k8s.io/yaml"
)
type remoteSecretArgs struct {
clusterName string
name string
namespace string
kubeconfig string
context string
}
type remoteManifestArgs struct {
clusterName string
webhookURL string
xdsAddress string
caAddress string
caBundleFile string
revision string
}
type eastWestGatewayArgs struct {
name string
namespace string
serviceType string
port int32
targetPort int
nodePort int32
xdsAddress string
}
func MulticlusterCmd() *cobra.Command {
command := &cobra.Command{
Use: "multicluster",
Short: "Manage remote clusters for dubbod",
}
command.AddCommand(createRemoteSecretCmd())
command.AddCommand(generateRemoteManifestCmd())
command.AddCommand(generateEastWestGatewayCmd())
return command
}
func createRemoteSecretCmd() *cobra.Command {
args := &remoteSecretArgs{
namespace: "dubbo-system",
}
command := &cobra.Command{
Use: "create-remote-secret",
Short: "Generate a remote-cluster Secret manifest",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
kubeconfig, err := remoteKubeconfig(args.kubeconfig, args.context)
if err != nil {
return err
}
secret, err := buildRemoteClusterSecret(*args, kubeconfig)
if err != nil {
return err
}
raw, err := yaml.Marshal(secret)
if err != nil {
return err
}
_, err = cmd.OutOrStdout().Write(raw)
return err
},
}
flags := command.Flags()
flags.StringVar(&args.clusterName, "cluster-name", "", "Unique remote cluster name")
flags.StringVar(&args.name, "name", "", "Secret name; defaults to dubbo-remote-<cluster-name>")
flags.StringVarP(&args.namespace, "namespace", "n", args.namespace, "Namespace where dubbod reads remote cluster Secrets")
flags.StringVar(&args.kubeconfig, "kubeconfig", "", "Remote cluster kubeconfig path")
flags.StringVar(&args.context, "context", "", "Remote cluster kubeconfig context")
_ = command.MarkFlagRequired("cluster-name")
return command
}
func generateRemoteManifestCmd() *cobra.Command {
args := &remoteManifestArgs{
revision: "default",
}
command := &cobra.Command{
Use: "generate-remote-manifest",
Short: "Generate remote-cluster injection webhook manifest",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
caBundle, err := os.ReadFile(args.caBundleFile)
if err != nil {
return fmt.Errorf("read CA bundle: %w", err)
}
manifest, err := buildRemoteWebhookManifest(*args, caBundle)
if err != nil {
return err
}
raw, err := yaml.Marshal(manifest)
if err != nil {
return err
}
_, err = cmd.OutOrStdout().Write(raw)
return err
},
}
flags := command.Flags()
flags.StringVar(&args.clusterName, "cluster-name", "", "Unique remote cluster name")
flags.StringVar(&args.webhookURL, "webhook-url", "", "Externally reachable dubbod webhook base URL")
flags.StringVar(&args.xdsAddress, "xds-address", "", "Externally reachable dubbod xDS address")
flags.StringVar(&args.caAddress, "ca-address", "", "Externally reachable dubbod CA address")
flags.StringVar(&args.caBundleFile, "ca-bundle-file", "", "PEM CA bundle file for webhook TLS verification")
flags.StringVar(&args.revision, "revision", args.revision, "Revision label used by the remote injector")
for _, flag := range []string{"cluster-name", "webhook-url", "xds-address", "ca-address", "ca-bundle-file"} {
_ = command.MarkFlagRequired(flag)
}
return command
}
func generateEastWestGatewayCmd() *cobra.Command {
args := &eastWestGatewayArgs{
name: "dubbod-eastwest-gateway",
namespace: "dubbo-system",
serviceType: "LoadBalancer",
port: 15443,
targetPort: inject.ProxylessGRPCInboundPort,
}
command := &cobra.Command{
Use: "generate-eastwest-gateway",
Short: "Generate an east-west Gateway manifest for a workload cluster",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
gw, err := buildEastWestGatewayManifest(*args)
if err != nil {
return err
}
raw, err := yaml.Marshal(gw)
if err != nil {
return err
}
_, err = cmd.OutOrStdout().Write(raw)
return err
},
}
flags := command.Flags()
flags.StringVar(&args.name, "name", args.name, "Gateway name")
flags.StringVarP(&args.namespace, "namespace", "n", args.namespace, "Gateway namespace")
flags.StringVar(&args.serviceType, "service-type", args.serviceType, "Managed gateway Service type")
flags.Int32Var(&args.port, "port", args.port, "Externally reachable east-west Gateway port")
flags.IntVar(&args.targetPort, "target-port", args.targetPort, "Injected grpc-inbound target port used by the managed gateway Service")
flags.Int32Var(&args.nodePort, "node-port", 0, "Optional managed gateway Service nodePort")
flags.StringVar(&args.xdsAddress, "xds-address", "", "ADS address used by the remote dxgate data plane")
_ = command.MarkFlagRequired("xds-address")
return command
}
func remoteKubeconfig(path, contextName string) ([]byte, error) {
rules := clientcmd.NewDefaultClientConfigLoadingRules()
if path != "" {
rules.ExplicitPath = path
}
rawConfig, err := rules.Load()
if err != nil {
return nil, fmt.Errorf("load kubeconfig: %w", err)
}
if contextName == "" {
contextName = rawConfig.CurrentContext
}
context, ok := rawConfig.Contexts[contextName]
if !ok {
return nil, fmt.Errorf("context %q not found", contextName)
}
minimal := clientcmdapi.Config{
Kind: rawConfig.Kind,
APIVersion: rawConfig.APIVersion,
CurrentContext: contextName,
Contexts: map[string]*clientcmdapi.Context{contextName: context.DeepCopy()},
Clusters: map[string]*clientcmdapi.Cluster{},
AuthInfos: map[string]*clientcmdapi.AuthInfo{},
}
if clusterConfig, ok := rawConfig.Clusters[context.Cluster]; ok {
minimal.Clusters[context.Cluster] = clusterConfig.DeepCopy()
}
if authInfo, ok := rawConfig.AuthInfos[context.AuthInfo]; ok {
minimal.AuthInfos[context.AuthInfo] = authInfo.DeepCopy()
}
return clientcmd.Write(minimal)
}
func buildEastWestGatewayManifest(args eastWestGatewayArgs) (*gatewayv1.Gateway, error) {
if strings.TrimSpace(args.name) == "" {
return nil, fmt.Errorf("name is required")
}
if strings.TrimSpace(args.namespace) == "" {
return nil, fmt.Errorf("namespace is required")
}
if args.port <= 0 || args.port > 65535 {
return nil, fmt.Errorf("port must be between 1 and 65535")
}
if args.targetPort <= 0 || args.targetPort > 65535 {
return nil, fmt.Errorf("target-port must be between 1 and 65535")
}
if args.nodePort < 0 || args.nodePort > 65535 {
return nil, fmt.Errorf("node-port must be between 0 and 65535")
}
if strings.TrimSpace(args.xdsAddress) == "" {
return nil, fmt.Errorf("xds-address is required")
}
annotations := map[string]string{
"gateway.dubbo.apache.org/eastwest": "true",
"gateway.dubbo.apache.org/service-type": args.serviceType,
"gateway.dubbo.apache.org/target-port": fmt.Sprintf("%d", args.targetPort),
"gateway.dubbo.apache.org/xds-address": args.xdsAddress,
}
if args.nodePort > 0 {
annotations["gateway.dubbo.apache.org/node-port"] = fmt.Sprintf("%d", args.nodePort)
}
return &gatewayv1.Gateway{
TypeMeta: metav1.TypeMeta{
APIVersion: gatewayv1.GroupVersion.String(),
Kind: "Gateway",
},
ObjectMeta: metav1.ObjectMeta{
Name: args.name,
Namespace: args.namespace,
Labels: map[string]string{
"app": "dubbod-eastwest-gateway",
"gateway.dubbo.apache.org/eastwest": "true",
},
Annotations: annotations,
},
Spec: gatewayv1.GatewaySpec{
GatewayClassName: gatewayv1.ObjectName("dubbo"),
Listeners: []gatewayv1.Listener{{
Name: gatewayv1.SectionName("http-eastwest"),
Protocol: gatewayv1.HTTPProtocolType,
Port: args.port,
AllowedRoutes: &gatewayv1.AllowedRoutes{
Namespaces: &gatewayv1.RouteNamespaces{
From: ptrGatewayFrom(gatewayv1.NamespacesFromAll),
},
},
}},
},
}, nil
}
func ptrGatewayFrom(value gatewayv1.FromNamespaces) *gatewayv1.FromNamespaces {
return &value
}
func buildRemoteClusterSecret(args remoteSecretArgs, kubeconfig []byte) (*corev1.Secret, error) {
if strings.TrimSpace(args.clusterName) == "" {
return nil, fmt.Errorf("cluster-name is required")
}
if args.name == "" {
args.name = "dubbo-remote-" + args.clusterName
}
return &corev1.Secret{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Secret",
},
ObjectMeta: metav1.ObjectMeta{
Name: args.name,
Namespace: args.namespace,
Labels: map[string]string{
multicluster.RemoteClusterSecretLabel: multicluster.RemoteClusterSecretLabelValue,
},
},
Type: corev1.SecretTypeOpaque,
Data: map[string][]byte{
multicluster.RemoteClusterSecretClusterNameKey: []byte(args.clusterName),
multicluster.RemoteClusterSecretKubeconfigKey: kubeconfig,
},
}, nil
}
func buildRemoteWebhookManifest(args remoteManifestArgs, caBundle []byte) (*admissionregistrationv1.MutatingWebhookConfiguration, error) {
if strings.TrimSpace(args.clusterName) == "" {
return nil, fmt.Errorf("cluster-name is required")
}
webhookURL, err := remoteWebhookURL(args.webhookURL, args.clusterName, args.xdsAddress, args.caAddress)
if err != nil {
return nil, err
}
sideEffects := admissionregistrationv1.SideEffectClassNone
failurePolicy := admissionregistrationv1.Fail
reinvocationPolicy := admissionregistrationv1.NeverReinvocationPolicy
scope := admissionregistrationv1.NamespacedScope
clientConfig := admissionregistrationv1.WebhookClientConfig{
URL: &webhookURL,
CABundle: caBundle,
}
return &admissionregistrationv1.MutatingWebhookConfiguration{
TypeMeta: metav1.TypeMeta{
APIVersion: admissionregistrationv1.SchemeGroupVersion.String(),
Kind: "MutatingWebhookConfiguration",
},
ObjectMeta: metav1.ObjectMeta{
Name: "dubbo-remote-proxyless-injector-" + args.clusterName,
Labels: map[string]string{
"app": "dubbod",
"dubbo.apache.org/rev": args.revision,
},
},
Webhooks: []admissionregistrationv1.MutatingWebhook{
remoteWebhook("rev.namespace.remote.proxyless-injector.dubbo.apache.org", clientConfig, &failurePolicy, &sideEffects, &reinvocationPolicy, &scope,
&metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{
{Key: "dubbo.apache.org/rev", Operator: metav1.LabelSelectorOpIn, Values: []string{args.revision}},
{Key: "dubbo-injection", Operator: metav1.LabelSelectorOpDoesNotExist},
}},
&metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{
{Key: "proxyless.dubbo.apache.org/inject", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"false"}},
}},
),
remoteWebhook("rev.object.remote.proxyless-injector.dubbo.apache.org", clientConfig, &failurePolicy, &sideEffects, &reinvocationPolicy, &scope,
&metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{
{Key: "dubbo.apache.org/rev", Operator: metav1.LabelSelectorOpDoesNotExist},
{Key: "dubbo-injection", Operator: metav1.LabelSelectorOpDoesNotExist},
}},
&metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{
{Key: "proxyless.dubbo.apache.org/inject", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"false"}},
{Key: "dubbo.apache.org/rev", Operator: metav1.LabelSelectorOpIn, Values: []string{args.revision}},
}},
),
remoteWebhook("namespace.remote.proxyless-injector.dubbo.apache.org", clientConfig, nil, &sideEffects, &reinvocationPolicy, &scope,
&metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{
{Key: "dubbo-injection", Operator: metav1.LabelSelectorOpIn, Values: []string{"enabled"}},
}},
&metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{
{Key: "proxyless.dubbo.apache.org/inject", Operator: metav1.LabelSelectorOpNotIn, Values: []string{"false"}},
}},
),
remoteWebhook("object.remote.proxyless-injector.dubbo.apache.org", clientConfig, nil, &sideEffects, &reinvocationPolicy, &scope,
&metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{
{Key: "dubbo-injection", Operator: metav1.LabelSelectorOpDoesNotExist},
}},
&metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{
{Key: "proxyless.dubbo.apache.org/inject", Operator: metav1.LabelSelectorOpIn, Values: []string{"true"}},
}},
),
},
}, nil
}
func remoteWebhook(name string, clientConfig admissionregistrationv1.WebhookClientConfig, failurePolicy *admissionregistrationv1.FailurePolicyType,
sideEffects *admissionregistrationv1.SideEffectClass, reinvocationPolicy *admissionregistrationv1.ReinvocationPolicyType,
scope *admissionregistrationv1.ScopeType, namespaceSelector, objectSelector *metav1.LabelSelector,
) admissionregistrationv1.MutatingWebhook {
return admissionregistrationv1.MutatingWebhook{
Name: name,
AdmissionReviewVersions: []string{"v1"},
ClientConfig: clientConfig,
FailurePolicy: failurePolicy,
NamespaceSelector: namespaceSelector,
ObjectSelector: objectSelector,
ReinvocationPolicy: reinvocationPolicy,
Rules: []admissionregistrationv1.RuleWithOperations{{
Operations: []admissionregistrationv1.OperationType{
admissionregistrationv1.Create,
},
Rule: admissionregistrationv1.Rule{
APIGroups: []string{""},
APIVersions: []string{"v1"},
Resources: []string{"pods"},
Scope: scope,
},
}, {
Operations: []admissionregistrationv1.OperationType{
admissionregistrationv1.Create,
admissionregistrationv1.Update,
},
Rule: admissionregistrationv1.Rule{
APIGroups: []string{""},
APIVersions: []string{"v1"},
Resources: []string{"services"},
Scope: scope,
},
}},
SideEffects: sideEffects,
}
}
func remoteWebhookURL(rawURL, clusterName, xdsAddress, caAddress string) (string, error) {
if strings.TrimSpace(xdsAddress) == "" || strings.TrimSpace(caAddress) == "" {
return "", fmt.Errorf("xds-address and ca-address are required")
}
u, err := url.Parse(rawURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return "", fmt.Errorf("webhook-url must be an absolute URL")
}
path := injectPath(clusterName, xdsAddress, caAddress)
u.Path = strings.TrimSuffix(u.Path, "/") + path
return u.String(), nil
}
func injectPath(clusterName, xdsAddress, caAddress string) string {
segments := []string{
"DUBBO_META_CLUSTER_ID", clusterName,
"XDS_ADDRESS", xdsAddress,
"CA_ADDRESS", caAddress,
}
for i := 1; i < len(segments); i += 2 {
segments[i] = strings.ReplaceAll(segments[i], "/", "--slash--")
}
return "/inject/" + strings.Join(segments, "/")
}
func encodedSecretData(secret *corev1.Secret, key string) string {
return base64.StdEncoding.EncodeToString(secret.Data[key])
}