diff --git a/backend/http/handler.go b/backend/http/handler.go
index 2df6394f4..5bb8b53cd 100644
--- a/backend/http/handler.go
+++ b/backend/http/handler.go
@@ -18,6 +18,7 @@ package http
import (
"context"
+ "encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -29,12 +30,15 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/serializer"
+ kjson "k8s.io/apimachinery/pkg/runtime/serializer/json"
componentbaseversion "k8s.io/component-base/version"
"k8s.io/klog/v2"
"github.com/kube-bind/kube-bind/backend/auth"
"github.com/kube-bind/kube-bind/backend/client"
"github.com/kube-bind/kube-bind/backend/kubernetes"
+ kuberesources "github.com/kube-bind/kube-bind/backend/kubernetes/resources"
"github.com/kube-bind/kube-bind/backend/oidc"
"github.com/kube-bind/kube-bind/backend/session"
"github.com/kube-bind/kube-bind/backend/spaserver"
@@ -149,6 +153,10 @@ func (h *handler) AddRoutes(mux *mux.Router) error {
// Public API routes (no authentication required)
mux.HandleFunc("/api/healthz", h.handleHealthz).Methods(http.MethodGet)
mux.HandleFunc("/api/bindable-resources", h.handleBindableResources).Methods(http.MethodGet)
+ // Intentionally unauthenticated: serves static, deterministic deployment YAML
+ // (konnector image tag is the only variable, derived from the server's own version).
+ // No secrets or cluster-specific data are included.
+ mux.HandleFunc("/api/konnector-manifests", h.handleKonnectorManifests).Methods(http.MethodGet)
// Generic authentication routes (support both UI and CLI)
mux.HandleFunc("/api/authorize", h.authHandler.HandleAuthorize).Methods(http.MethodGet, http.MethodPost)
@@ -166,6 +174,8 @@ func (h *handler) AddRoutes(mux *mux.Router) error {
apiRouter.Handle("/collections", auth.RequireAuth(http.HandlerFunc(h.handleCollections))).Methods(http.MethodGet)
apiRouter.Handle("/bind", auth.RequireAuth(http.HandlerFunc(h.handleBind))).Methods(http.MethodPost)
apiRouter.Handle("/ping", auth.RequireAuth(http.HandlerFunc(h.handlePing))).Methods(http.MethodGet)
+ apiRouter.Handle("/consumer-status", auth.RequireAuth(http.HandlerFunc(h.handleConsumerStatus))).Methods(http.MethodGet)
+ apiRouter.Handle("/apply-binding", auth.RequireAuth(http.HandlerFunc(h.handleApplyBinding))).Methods(http.MethodPost)
if h.oidcServer != nil {
h.oidcServer.AddRoutes(mux)
@@ -197,6 +207,49 @@ func (h *handler) handlePing(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("pong")) //nolint:errcheck
}
+// handleKonnectorManifests returns the pre-rendered konnector YAML manifests
+// that a consumer cluster needs to apply to deploy the konnector agent.
+// The manifests are generated from the same Go structs used by the one-click
+// apply flow (ensureKonnector) to avoid definition drift.
+func (h *handler) handleKonnectorManifests(w http.ResponseWriter, r *http.Request) {
+ prepareNoCache(w)
+
+ konnectorVersion, err := bindversion.BinaryVersion(componentbaseversion.Get().GitVersion)
+ if err != nil {
+ konnectorVersion = "latest"
+ }
+ konnectorImage := fmt.Sprintf("ghcr.io/kube-bind/konnector:%s", konnectorVersion)
+
+ manifests := kuberesources.NewKonnectorManifests(konnectorImage, nil)
+
+ // Serialize each object to YAML and join with document separators
+ s := runtime.NewScheme()
+ kuberesources.AddKonnectorSchemes(s)
+ encoder := kjson.NewSerializerWithOptions(
+ kjson.DefaultMetaFactory,
+ s,
+ s,
+ kjson.SerializerOptions{Yaml: true, Pretty: true, Strict: false},
+ )
+ codec := serializer.NewCodecFactory(s).EncoderForVersion(encoder, nil)
+
+ var buf strings.Builder
+ objects := manifests.Objects()
+ for i, obj := range objects {
+ if i > 0 {
+ buf.WriteString("---\n")
+ }
+ if err := codec.Encode(obj, &buf); err != nil {
+ writeErrorResponse(w, http.StatusInternalServerError, kubebindv1alpha2.ErrorCodeInternalError, "Failed to serialize konnector manifests", err.Error())
+ return
+ }
+ }
+
+ w.Header().Set("Content-Type", "text/yaml")
+ w.Header().Set("Content-Disposition", "attachment; filename=konnector.yaml")
+ w.Write([]byte(buf.String())) //nolint:errcheck
+}
+
func (h *handler) handleLogout(w http.ResponseWriter, r *http.Request) {
prepareNoCache(w)
@@ -368,7 +421,8 @@ func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) {
}
// Resolve the UI sentinel to a real identity derived from the authenticated session.
- if identity == auth.UIIdentity {
+ isUIFlow := identity == auth.UIIdentity
+ if isUIFlow {
identity = state.Token.Issuer + "/" + state.Token.Subject
logger.Info("Resolved ui-identity from session", "identity", identity)
}
@@ -411,6 +465,26 @@ func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) {
},
}
+ // For UI-only flow, create the APIServiceExportRequest on the provider cluster
+ // and wait for reconciliation. In CLI flow the konnector handles this instead.
+ var exportRequestName string
+ if isUIFlow {
+ exportRequest, err := h.kubeManager.CreateAPIServiceExportRequest(
+ r.Context(),
+ params.ClusterID,
+ handleResult.Namespace,
+ bindRequest.Name,
+ request.Spec,
+ )
+ if err != nil {
+ logger.Error(err, "failed to create APIServiceExportRequest")
+ statusCode, code, details := mapErrorToCode(err)
+ writeErrorResponse(w, statusCode, code, "Failed to create API service export request", details)
+ return
+ }
+ exportRequestName = exportRequest.Name
+ }
+
// callback response
requestBytes, err := json.Marshal(&request)
if err != nil {
@@ -430,8 +504,10 @@ func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) {
ID: state.Token.Issuer + "/" + state.Token.Subject,
},
},
- Kubeconfig: handleResult.Kubeconfig,
- Requests: []runtime.RawExtension{{Raw: requestBytes}},
+ Kubeconfig: handleResult.Kubeconfig,
+ Requests: []runtime.RawExtension{{Raw: requestBytes}},
+ ProviderNamespace: handleResult.Namespace,
+ BindingName: exportRequestName,
}
payload, err := json.Marshal(&response)
@@ -445,7 +521,120 @@ func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) {
w.Write(payload) //nolint:errcheck
}
-// listTemplates fetches the list of APIServiceExportTemplates from the backend cluster without checking
+// handleConsumerStatus returns whether the authenticated user already has a consumer
+// namespace with existing APIServiceExports on the provider.
+func (h *handler) handleConsumerStatus(w http.ResponseWriter, r *http.Request) {
+ logger := getLogger(r)
+ params := client.GetQueryParams(r)
+ prepareNoCache(w)
+
+ authCtx := auth.GetAuthContext(r.Context())
+ state := authCtx.SessionState
+ identity := state.Token.Issuer + "/" + state.Token.Subject
+
+ status, err := h.kubeManager.GetConsumerStatus(r.Context(), identity, params.ClusterID)
+ if err != nil {
+ logger.Error(err, "failed to get consumer status")
+ writeErrorResponse(w, http.StatusInternalServerError, kubebindv1alpha2.ErrorCodeInternalError, "Failed to get consumer status", err.Error())
+ return
+ }
+
+ payload, err := json.Marshal(status)
+ if err != nil {
+ logger.Error(err, "failed to marshal consumer status")
+ writeErrorResponse(w, http.StatusInternalServerError, kubebindv1alpha2.ErrorCodeInternalError, "Failed to marshal consumer status", err.Error())
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(payload) //nolint:errcheck
+}
+
+// applyBindingRequest is the JSON body for the apply-binding endpoint.
+type applyBindingRequest struct {
+ // ConsumerKubeconfig is the base64-encoded kubeconfig for the consumer cluster.
+ ConsumerKubeconfig string `json:"consumerKubeconfig"`
+ // BindingName is the name for the binding (used for secret and bundle naming).
+ BindingName string `json:"bindingName"`
+}
+
+// handleApplyBinding receives a consumer kubeconfig and applies the konnector + binding
+// bundle to the consumer cluster.
+func (h *handler) handleApplyBinding(w http.ResponseWriter, r *http.Request) {
+ logger := getLogger(r)
+ params := client.GetQueryParams(r)
+ prepareNoCache(w)
+
+ authCtx := auth.GetAuthContext(r.Context())
+ state := authCtx.SessionState
+ identity := state.Token.Issuer + "/" + state.Token.Subject
+
+ // Parse request body
+ const maxBodySize = 1 << 20 // 1 MB
+ r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
+ var req applyBindingRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeErrorResponse(w, http.StatusBadRequest, kubebindv1alpha2.ErrorCodeBadRequest, "Invalid request body", err.Error())
+ return
+ }
+
+ if req.ConsumerKubeconfig == "" {
+ writeErrorResponse(w, http.StatusBadRequest, kubebindv1alpha2.ErrorCodeBadRequest, "Missing consumer kubeconfig", "consumerKubeconfig is required")
+ return
+ }
+ if req.BindingName == "" {
+ writeErrorResponse(w, http.StatusBadRequest, kubebindv1alpha2.ErrorCodeBadRequest, "Missing binding name", "bindingName is required")
+ return
+ }
+
+ // Decode base64 consumer kubeconfig
+ consumerKubeconfigData, err := base64.StdEncoding.DecodeString(req.ConsumerKubeconfig)
+ if err != nil {
+ writeErrorResponse(w, http.StatusBadRequest, kubebindv1alpha2.ErrorCodeBadRequest, "Invalid consumer kubeconfig encoding", "consumerKubeconfig must be base64 encoded")
+ return
+ }
+
+ // Get the provider kubeconfig for this user's namespace
+ handleResult, err := h.kubeManager.HandleResources(r.Context(), state.Token.Subject, identity, params.ClusterID)
+ if err != nil {
+ logger.Error(err, "failed to handle resources for apply-binding")
+ statusCode, code, details := mapErrorToCode(err)
+ writeErrorResponse(w, statusCode, code, "Failed to prepare provider resources", details)
+ return
+ }
+
+ // Resolve konnector image
+ konnectorVersion, err := bindversion.BinaryVersion(componentbaseversion.Get().GitVersion)
+ if err != nil {
+ konnectorVersion = "latest"
+ }
+ konnectorImage := fmt.Sprintf("ghcr.io/kube-bind/konnector:%s", konnectorVersion)
+
+ // Apply to consumer cluster
+ result, err := h.kubeManager.ApplyToConsumer(
+ r.Context(),
+ consumerKubeconfigData,
+ handleResult.Kubeconfig,
+ req.BindingName,
+ konnectorImage,
+ )
+ if err != nil {
+ logger.Error(err, "failed to apply binding to consumer cluster")
+ writeErrorResponse(w, http.StatusInternalServerError, kubebindv1alpha2.ErrorCodeInternalError, "Failed to apply binding to consumer cluster", err.Error())
+ return
+ }
+
+ payload, err := json.Marshal(result)
+ if err != nil {
+ logger.Error(err, "failed to marshal apply result")
+ writeErrorResponse(w, http.StatusInternalServerError, kubebindv1alpha2.ErrorCodeInternalError, "Failed to marshal result", err.Error())
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(payload) //nolint:errcheck
+}
+
// if they are part of a Collection or not.
func (h *handler) listTemplates(ctx context.Context, cluster string) (*kubebindv1alpha2.APIServiceExportTemplateList, error) {
templates, err := h.kubeManager.ListTemplates(ctx, cluster)
diff --git a/backend/kubernetes/manager.go b/backend/kubernetes/manager.go
index 743ea8e84..424e46e18 100644
--- a/backend/kubernetes/manager.go
+++ b/backend/kubernetes/manager.go
@@ -19,8 +19,12 @@ package kubernetes
import (
"context"
"fmt"
+ "net"
+ "net/url"
"strings"
+ "time"
+ appsv1 "k8s.io/api/apps/v1"
authzv1 "k8s.io/api/authorization/v1"
corev1 "k8s.io/api/core/v1"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
@@ -30,7 +34,10 @@ import (
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
+ "k8s.io/apimachinery/pkg/util/wait"
+ "k8s.io/client-go/kubernetes/scheme"
authorizationv1 "k8s.io/client-go/kubernetes/typed/authorization/v1"
+ "k8s.io/client-go/tools/clientcmd"
"k8s.io/klog/v2"
"sigs.k8s.io/controller-runtime/pkg/client"
mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"
@@ -173,6 +180,80 @@ func (m *Manager) HandleResources(
}, nil
}
+// CreateAPIServiceExportRequest creates an APIServiceExportRequest in the given namespace
+// on the provider cluster and waits for it to be reconciled (Succeeded or Failed).
+func (m *Manager) CreateAPIServiceExportRequest(
+ ctx context.Context,
+ cluster, namespace, name string,
+ spec kubebindv1alpha2.APIServiceExportRequestSpec,
+) (*kubebindv1alpha2.APIServiceExportRequest, error) {
+ logger := klog.FromContext(ctx).WithValues("namespace", namespace, "name", name)
+
+ cl, err := m.manager.GetCluster(ctx, cluster)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get cluster client: %w", err)
+ }
+ c := cl.GetClient()
+
+ exportRequest := &kubebindv1alpha2.APIServiceExportRequest{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: namespace,
+ },
+ Spec: spec,
+ }
+
+ // Create the APIServiceExportRequest, handling name conflicts
+ if err := c.Create(ctx, exportRequest); err != nil {
+ if !errors.IsAlreadyExists(err) {
+ return nil, fmt.Errorf("failed to create APIServiceExportRequest: %w", err)
+ }
+ // Name conflict: use generateName
+ exportRequest.Name = ""
+ exportRequest.GenerateName = name + "-"
+ if err := c.Create(ctx, exportRequest); err != nil {
+ return nil, fmt.Errorf("failed to create APIServiceExportRequest with generated name: %w", err)
+ }
+ }
+
+ createdName := exportRequest.Name
+ logger = logger.WithValues("createdName", createdName)
+ logger.Info("Created APIServiceExportRequest, waiting for reconciliation")
+
+ // Poll until reconciled. The client reads from cache, so the object may not
+ // be visible immediately after creation. Tolerate NotFound for an initial
+ // grace period before treating it as a real deletion.
+ var result *kubebindv1alpha2.APIServiceExportRequest
+ seenOnce := false
+ if err := wait.PollUntilContextTimeout(ctx, 1*time.Second, 60*time.Second, false, func(ctx context.Context) (bool, error) {
+ req := &kubebindv1alpha2.APIServiceExportRequest{}
+ if err := c.Get(ctx, types.NamespacedName{Namespace: namespace, Name: createdName}, req); err != nil {
+ if errors.IsNotFound(err) {
+ if seenOnce {
+ return false, fmt.Errorf("APIServiceExportRequest %s was deleted", createdName)
+ }
+ // Cache hasn't synced yet — keep polling.
+ return false, nil
+ }
+ return false, err
+ }
+ seenOnce = true
+ if req.Status.Phase == kubebindv1alpha2.APIServiceExportRequestPhaseSucceeded {
+ result = req
+ return true, nil
+ }
+ if req.Status.Phase == kubebindv1alpha2.APIServiceExportRequestPhaseFailed {
+ return false, fmt.Errorf("APIServiceExportRequest failed: %s", req.Status.TerminalMessage)
+ }
+ return false, nil
+ }); err != nil {
+ return nil, fmt.Errorf("waiting for APIServiceExportRequest: %w", err)
+ }
+
+ logger.Info("APIServiceExportRequest reconciled successfully")
+ return result, nil
+}
+
func (m *Manager) ListCustomResourceDefinitions(ctx context.Context, cluster string, selector labels.Selector) (*apiextensionsv1.CustomResourceDefinitionList, error) {
cl, err := m.manager.GetCluster(ctx, cluster)
if err != nil {
@@ -315,6 +396,290 @@ func (m *Manager) AuthorizeRequest(ctx context.Context, subject string, groups [
return nil
}
+// ConsumerStatus describes the status of a consumer cluster relative to the provider.
+type ConsumerStatus struct {
+ // Connected is true if the consumer has an existing provider namespace.
+ Connected bool `json:"connected"`
+ // Namespace is the provider namespace for this consumer.
+ Namespace string `json:"namespace,omitempty"`
+ // Exports is the list of APIServiceExport names in the consumer's namespace.
+ Exports []string `json:"exports,omitempty"`
+}
+
+// GetConsumerStatus checks if the given identity already has a provider namespace
+// with existing APIServiceExports.
+func (m *Manager) GetConsumerStatus(ctx context.Context, identity, cluster string) (*ConsumerStatus, error) {
+ logger := klog.FromContext(ctx).WithValues("identity", identity)
+
+ cl, err := m.manager.GetCluster(ctx, cluster)
+ if err != nil {
+ return nil, err
+ }
+ c := cl.GetClient()
+
+ // Look up namespace by identity annotation
+ var nss corev1.NamespaceList
+ err = c.List(ctx, &nss, client.MatchingFields{NamespacesByIdentity: identity})
+ if err != nil {
+ return nil, err
+ }
+
+ if len(nss.Items) == 0 {
+ return &ConsumerStatus{Connected: false}, nil
+ }
+
+ ns := nss.Items[0].Name
+ logger.Info("Found existing consumer namespace", "namespace", ns)
+
+ // List APIServiceExports in the consumer namespace
+ var exports kubebindv1alpha2.APIServiceExportList
+ if err := c.List(ctx, &exports, client.InNamespace(ns)); err != nil {
+ return nil, fmt.Errorf("failed to list APIServiceExports: %w", err)
+ }
+
+ exportNames := make([]string, 0, len(exports.Items))
+ for _, e := range exports.Items {
+ exportNames = append(exportNames, e.Name)
+ }
+
+ return &ConsumerStatus{
+ Connected: len(exportNames) > 0,
+ Namespace: ns,
+ Exports: exportNames,
+ }, nil
+}
+
+// ApplyToConsumerResult contains the result of ApplyToConsumer operation.
+type ApplyToConsumerResult struct {
+ KonnectorDeployed bool `json:"konnectorDeployed"`
+ BundleCreated bool `json:"bundleCreated"`
+ Message string `json:"message"`
+}
+
+// ApplyToConsumer applies konnector manifests and binding bundle to a consumer cluster
+// using the provided consumer kubeconfig.
+func (m *Manager) ApplyToConsumer(
+ ctx context.Context,
+ consumerKubeconfigData []byte,
+ providerKubeconfigData []byte,
+ bindingName string,
+ konnectorImage string,
+) (*ApplyToConsumerResult, error) {
+ logger := klog.FromContext(ctx).WithValues("bindingName", bindingName)
+
+ // Build a client from the consumer kubeconfig
+ consumerConfig, err := clientcmd.RESTConfigFromKubeConfig(consumerKubeconfigData)
+ if err != nil {
+ return nil, fmt.Errorf("invalid consumer kubeconfig: %w", err)
+ }
+
+ s := scheme.Scheme
+ if err := kubebindv1alpha2.AddToScheme(s); err != nil {
+ return nil, fmt.Errorf("failed to add kube-bind scheme: %w", err)
+ }
+ if err := apiextensionsv1.AddToScheme(s); err != nil {
+ return nil, fmt.Errorf("failed to add apiextensions scheme: %w", err)
+ }
+
+ consumerClient, err := client.New(consumerConfig, client.Options{Scheme: s})
+ if err != nil {
+ return nil, fmt.Errorf("failed to create consumer client: %w", err)
+ }
+
+ // 1. Ensure kube-bind namespace
+ ns := &corev1.Namespace{
+ ObjectMeta: metav1.ObjectMeta{Name: "kube-bind"},
+ }
+ if err := consumerClient.Create(ctx, ns); err != nil && !errors.IsAlreadyExists(err) {
+ return nil, fmt.Errorf("failed to create kube-bind namespace: %w", err)
+ }
+
+ // 2. Resolve host aliases from the provider kubeconfig so the konnector
+ // can reach the provider API server (needed in Kind/Docker environments
+ // where the hostname resolves to localhost on the host but not in pods).
+ hostAliases := m.resolveProviderHostAliases(ctx, providerKubeconfigData)
+
+ // 3. Deploy konnector (idempotent)
+ konnectorDeployed, err := m.ensureKonnector(ctx, consumerClient, konnectorImage, hostAliases)
+ if err != nil {
+ return nil, fmt.Errorf("failed to deploy konnector: %w", err)
+ }
+
+ // 3b. If konnector was newly deployed, wait for it to bootstrap CRDs
+ if konnectorDeployed {
+ logger.Info("Waiting for konnector to bootstrap CRDs on consumer cluster")
+ if err := m.waitForCRD(ctx, consumerClient, "apiservicebindingbundles.kube-bind.io"); err != nil {
+ return nil, fmt.Errorf("timed out waiting for konnector CRDs: %w", err)
+ }
+ }
+
+ // 4. Create kubeconfig secret for the provider
+ secretName := fmt.Sprintf("kubeconfig-%s", bindingName)
+ kubeconfigSecret := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: secretName,
+ Namespace: "kube-bind",
+ },
+ Type: corev1.SecretTypeOpaque,
+ Data: map[string][]byte{
+ "kubeconfig": providerKubeconfigData,
+ },
+ }
+ if err := consumerClient.Create(ctx, kubeconfigSecret); err != nil {
+ if errors.IsAlreadyExists(err) {
+ // Update existing secret
+ existing := &corev1.Secret{}
+ if err := consumerClient.Get(ctx, types.NamespacedName{Name: secretName, Namespace: "kube-bind"}, existing); err != nil {
+ return nil, fmt.Errorf("failed to get existing kubeconfig secret: %w", err)
+ }
+ existing.Data = kubeconfigSecret.Data
+ if err := consumerClient.Update(ctx, existing); err != nil {
+ return nil, fmt.Errorf("failed to update kubeconfig secret: %w", err)
+ }
+ } else {
+ return nil, fmt.Errorf("failed to create kubeconfig secret: %w", err)
+ }
+ }
+
+ // 5. Create APIServiceBindingBundle
+ bundle := &kubebindv1alpha2.APIServiceBindingBundle{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: bindingName,
+ },
+ Spec: kubebindv1alpha2.APIServiceBindingBundleSpec{
+ KubeconfigSecretRef: kubebindv1alpha2.ClusterSecretKeyRef{
+ LocalSecretKeyRef: kubebindv1alpha2.LocalSecretKeyRef{
+ Name: secretName,
+ Key: "kubeconfig",
+ },
+ Namespace: "kube-bind",
+ },
+ },
+ }
+ if err := consumerClient.Create(ctx, bundle); err != nil {
+ if errors.IsAlreadyExists(err) {
+ logger.Info("APIServiceBindingBundle already exists, updating")
+ existing := &kubebindv1alpha2.APIServiceBindingBundle{}
+ if err := consumerClient.Get(ctx, types.NamespacedName{Name: bindingName}, existing); err != nil {
+ return nil, fmt.Errorf("failed to get existing bundle: %w", err)
+ }
+ existing.Spec = bundle.Spec
+ if err := consumerClient.Update(ctx, existing); err != nil {
+ return nil, fmt.Errorf("failed to update bundle: %w", err)
+ }
+ } else {
+ return nil, fmt.Errorf("failed to create APIServiceBindingBundle: %w", err)
+ }
+ }
+
+ logger.Info("Successfully applied binding to consumer cluster")
+ return &ApplyToConsumerResult{
+ KonnectorDeployed: konnectorDeployed,
+ BundleCreated: true,
+ Message: "Konnector and binding bundle applied successfully",
+ }, nil
+}
+
+// resolveProviderHostAliases extracts the provider API server hostname from the
+// kubeconfig and resolves it to IP addresses. This allows the konnector pods on
+// the consumer cluster to reach the provider even when the hostname only resolves
+// correctly from the backend pod's network (e.g., Kind/Docker environments).
+func (m *Manager) resolveProviderHostAliases(ctx context.Context, providerKubeconfigData []byte) []corev1.HostAlias {
+ config, err := clientcmd.RESTConfigFromKubeConfig(providerKubeconfigData)
+ if err != nil {
+ return nil
+ }
+
+ u, err := url.Parse(config.Host)
+ if err != nil {
+ return nil
+ }
+
+ hostname := u.Hostname()
+ if hostname == "" {
+ return nil
+ }
+
+ if net.ParseIP(hostname) != nil {
+ return nil
+ }
+
+ ips, err := net.DefaultResolver.LookupHost(ctx, hostname)
+ if err != nil || len(ips) == 0 {
+ return nil
+ }
+
+ var validIPs []string
+ for _, ip := range ips {
+ parsed := net.ParseIP(ip)
+ if parsed != nil && !parsed.IsLoopback() {
+ validIPs = append(validIPs, ip)
+ }
+ }
+
+ if len(validIPs) == 0 {
+ return nil
+ }
+
+ return []corev1.HostAlias{
+ {
+ IP: validIPs[0],
+ Hostnames: []string{hostname},
+ },
+ }
+}
+
+// ensureKonnector deploys the konnector agent to the consumer cluster.
+// Returns true if the konnector was newly deployed, false if it already existed.
+func (m *Manager) ensureKonnector(ctx context.Context, c client.Client, konnectorImage string, hostAliases []corev1.HostAlias) (bool, error) {
+ // Check if konnector deployment already exists
+ existing := &appsv1.Deployment{}
+ err := c.Get(ctx, types.NamespacedName{Name: kuberesources.KonnectorDeploymentName, Namespace: kuberesources.KonnectorNamespace}, existing)
+ if err == nil {
+ return false, nil // already deployed
+ }
+ if !errors.IsNotFound(err) {
+ return false, fmt.Errorf("failed to check for existing konnector: %w", err)
+ }
+
+ manifests := kuberesources.NewKonnectorManifests(konnectorImage, hostAliases)
+
+ if err := c.Create(ctx, manifests.ServiceAccount); err != nil && !errors.IsAlreadyExists(err) {
+ return false, fmt.Errorf("failed to create konnector service account: %w", err)
+ }
+ if err := c.Create(ctx, manifests.ClusterRole); err != nil && !errors.IsAlreadyExists(err) {
+ return false, fmt.Errorf("failed to create konnector cluster role: %w", err)
+ }
+ if err := c.Create(ctx, manifests.ClusterRoleBinding); err != nil && !errors.IsAlreadyExists(err) {
+ return false, fmt.Errorf("failed to create konnector cluster role binding: %w", err)
+ }
+ if err := c.Create(ctx, manifests.Deployment); err != nil {
+ return false, fmt.Errorf("failed to create konnector deployment: %w", err)
+ }
+
+ return true, nil
+}
+
+func (m *Manager) waitForCRD(ctx context.Context, c client.Client, crdName string) error {
+ return wait.PollUntilContextTimeout(ctx, 2*time.Second, 120*time.Second, true, func(ctx context.Context) (bool, error) {
+ crd := &apiextensionsv1.CustomResourceDefinition{}
+ err := c.Get(ctx, types.NamespacedName{Name: crdName}, crd)
+ if err != nil {
+ if errors.IsNotFound(err) {
+ return false, nil // keep polling
+ }
+ return false, nil // transient error, keep polling
+ }
+ // Check if the CRD is established
+ for _, cond := range crd.Status.Conditions {
+ if cond.Type == apiextensionsv1.Established && cond.Status == apiextensionsv1.ConditionTrue {
+ return true, nil
+ }
+ }
+ return false, nil
+ })
+}
+
func (m *Manager) SeedDefaultCluster(ctx context.Context) error {
logger := klog.FromContext(ctx)
diff --git a/backend/kubernetes/resources/konnector.go b/backend/kubernetes/resources/konnector.go
new file mode 100644
index 000000000..dfa5879c9
--- /dev/null
+++ b/backend/kubernetes/resources/konnector.go
@@ -0,0 +1,170 @@
+/*
+Copyright 2026 The Kube Bind Authors.
+
+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 resources
+
+import (
+ appsv1 "k8s.io/api/apps/v1"
+ corev1 "k8s.io/api/core/v1"
+ rbacv1 "k8s.io/api/rbac/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/util/intstr"
+ utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+)
+
+const (
+ KonnectorNamespace = "kube-bind"
+ KonnectorServiceAccountName = "konnector"
+ KonnectorClusterRoleName = "kube-bind-konnector"
+ KonnectorDeploymentName = "konnector"
+ KonnectorReplicas = 2
+ KonnectorHealthzPort = 8090
+)
+
+// KonnectorManifests holds typed Kubernetes objects for deploying the konnector agent.
+type KonnectorManifests struct {
+ Namespace *corev1.Namespace
+ ServiceAccount *corev1.ServiceAccount
+ ClusterRole *rbacv1.ClusterRole
+ ClusterRoleBinding *rbacv1.ClusterRoleBinding
+ Deployment *appsv1.Deployment
+}
+
+// NewKonnectorManifests returns the full set of Kubernetes objects needed to deploy
+// the konnector agent to a consumer cluster.
+func NewKonnectorManifests(konnectorImage string, hostAliases []corev1.HostAlias) *KonnectorManifests {
+ replicas := int32(KonnectorReplicas)
+ httpPort := intstr.FromInt(KonnectorHealthzPort)
+
+ return &KonnectorManifests{
+ Namespace: &corev1.Namespace{
+ ObjectMeta: metav1.ObjectMeta{Name: KonnectorNamespace},
+ },
+ ServiceAccount: &corev1.ServiceAccount{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: KonnectorServiceAccountName,
+ Namespace: KonnectorNamespace,
+ },
+ },
+ // Broad access is required because the konnector dynamically manages CRDs
+ // and syncs arbitrary resource types discovered from the provider. Scoping
+ // down would require knowing the bound resource types in advance, which
+ // defeats the auto-discovery model.
+ ClusterRole: &rbacv1.ClusterRole{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: KonnectorClusterRoleName,
+ },
+ Rules: []rbacv1.PolicyRule{
+ {
+ APIGroups: []string{"*"},
+ Resources: []string{"*"},
+ Verbs: []string{"*"},
+ },
+ },
+ },
+ ClusterRoleBinding: &rbacv1.ClusterRoleBinding{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: KonnectorClusterRoleName,
+ },
+ RoleRef: rbacv1.RoleRef{
+ APIGroup: "rbac.authorization.k8s.io",
+ Kind: "ClusterRole",
+ Name: KonnectorClusterRoleName,
+ },
+ Subjects: []rbacv1.Subject{
+ {
+ Kind: "ServiceAccount",
+ Name: KonnectorServiceAccountName,
+ Namespace: KonnectorNamespace,
+ },
+ },
+ },
+ Deployment: &appsv1.Deployment{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: KonnectorDeploymentName,
+ Namespace: KonnectorNamespace,
+ Labels: map[string]string{"app": "konnector"},
+ },
+ Spec: appsv1.DeploymentSpec{
+ Replicas: &replicas,
+ Selector: &metav1.LabelSelector{
+ MatchLabels: map[string]string{"app": "konnector"},
+ },
+ Template: corev1.PodTemplateSpec{
+ ObjectMeta: metav1.ObjectMeta{
+ Labels: map[string]string{"app": "konnector"},
+ },
+ Spec: corev1.PodSpec{
+ RestartPolicy: corev1.RestartPolicyAlways,
+ ServiceAccountName: KonnectorServiceAccountName,
+ HostAliases: hostAliases,
+ Containers: []corev1.Container{
+ {
+ Name: "konnector",
+ Image: konnectorImage,
+ Env: []corev1.EnvVar{
+ {
+ Name: "POD_NAME",
+ ValueFrom: &corev1.EnvVarSource{
+ FieldRef: &corev1.ObjectFieldSelector{
+ FieldPath: "metadata.name",
+ },
+ },
+ },
+ {
+ Name: "POD_NAMESPACE",
+ ValueFrom: &corev1.EnvVarSource{
+ FieldRef: &corev1.ObjectFieldSelector{
+ FieldPath: "metadata.namespace",
+ },
+ },
+ },
+ },
+ ReadinessProbe: &corev1.Probe{
+ ProbeHandler: corev1.ProbeHandler{
+ HTTPGet: &corev1.HTTPGetAction{
+ Path: "/healthz",
+ Port: httpPort,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+// Objects returns all konnector manifests as a slice of runtime.Object for serialization.
+func (m *KonnectorManifests) Objects() []runtime.Object {
+ return []runtime.Object{
+ m.Namespace,
+ m.ServiceAccount,
+ m.ClusterRole,
+ m.ClusterRoleBinding,
+ m.Deployment,
+ }
+}
+
+// AddKonnectorSchemes registers the schemes needed to serialize konnector manifests.
+func AddKonnectorSchemes(s *runtime.Scheme) {
+ utilruntime.Must(corev1.AddToScheme(s))
+ utilruntime.Must(appsv1.AddToScheme(s))
+ utilruntime.Must(rbacv1.AddToScheme(s))
+}
diff --git a/sdk/apis/kubebind/v1alpha2/bindingresponse_types.go b/sdk/apis/kubebind/v1alpha2/bindingresponse_types.go
index 54c2e2323..2e470ff39 100644
--- a/sdk/apis/kubebind/v1alpha2/bindingresponse_types.go
+++ b/sdk/apis/kubebind/v1alpha2/bindingresponse_types.go
@@ -55,6 +55,21 @@ type BindingResourceResponse struct {
// +kubebuilder:validation:Required
// +kubebuilder:validation:MinItems=1
Requests []runtime.RawExtension `json:"requests"`
+
+ // providerNamespace is the namespace on the service provider cluster where the
+ // binding resources (APIServiceExport, BoundSchema, etc.) are created and managed.
+ // This is set when the binding is created via the UI flow.
+ //
+ // +optional
+ // +kubebuilder:validation:Optional
+ ProviderNamespace string `json:"providerNamespace,omitempty"`
+
+ // bindingName is the confirmed name for this binding, as created on the service
+ // provider cluster.
+ //
+ // +optional
+ // +kubebuilder:validation:Optional
+ BindingName string `json:"bindingName,omitempty"`
}
// BindingResponseAuthentication is the authentication data specific to the
diff --git a/web/dist/assets/index.CxJLQV_T.css b/web/dist/assets/index.-8-K3ENk.css
similarity index 70%
rename from web/dist/assets/index.CxJLQV_T.css
rename to web/dist/assets/index.-8-K3ENk.css
index e14563765..98f3f8ef6 100644
--- a/web/dist/assets/index.CxJLQV_T.css
+++ b/web/dist/assets/index.-8-K3ENk.css
@@ -174,7 +174,7 @@
line-height: 1.5;
}
-.binding-modal-overlay[data-v-e150bd4a] {
+.binding-modal-overlay[data-v-21112df8] {
position: fixed;
top: 0;
left: 0;
@@ -187,7 +187,7 @@
z-index: 1000;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
}
-.binding-modal[data-v-e150bd4a] {
+.binding-modal[data-v-21112df8] {
background: white;
border-radius: 12px;
width: 90%;
@@ -196,21 +196,21 @@
overflow: hidden;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
-.binding-header[data-v-e150bd4a] {
+.binding-header[data-v-21112df8] {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
border-bottom: 1px solid #e5e7eb;
- background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+ background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
}
-.binding-header h3[data-v-e150bd4a] {
+.binding-header h3[data-v-21112df8] {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
-.close-btn[data-v-e150bd4a] {
+.close-btn[data-v-21112df8] {
background: none;
border: none;
font-size: 1.5rem;
@@ -224,85 +224,112 @@
border-radius: 50%;
transition: all 0.2s;
}
-.close-btn[data-v-e150bd4a]:hover {
+.close-btn[data-v-21112df8]:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
}
-.binding-content[data-v-e150bd4a] {
+.binding-content[data-v-21112df8] {
padding: 2rem;
max-height: 70vh;
overflow-y: auto;
}
-.binding-info[data-v-e150bd4a] {
- margin-bottom: 2rem;
+.binding-info[data-v-21112df8] {
+ margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
-.binding-info h4[data-v-e150bd4a] {
+.binding-info h4[data-v-21112df8] {
margin-bottom: 1rem;
color: #111827;
font-size: 1.125rem;
font-weight: 600;
}
-.binding-info p[data-v-e150bd4a] {
+.binding-info p[data-v-21112df8] {
margin: 0.5rem 0;
color: #6b7280;
font-size: 0.9rem;
}
-.instructions-section[data-v-e150bd4a] {
- margin-bottom: 2rem;
+.method-tabs[data-v-21112df8] {
+ display: flex;
+ gap: 0;
+ margin-bottom: 1.5rem;
+ border-bottom: 2px solid #e5e7eb;
+ flex-wrap: wrap;
+}
+.method-tab[data-v-21112df8] {
+ padding: 0.75rem 1.25rem;
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -2px;
+ cursor: pointer;
+ font-size: 0.85rem;
+ font-weight: 500;
+ color: #6b7280;
+ transition: all 0.2s;
+ white-space: nowrap;
+}
+.method-tab[data-v-21112df8]:hover {
+ color: #374151;
}
-.instructions-section h4[data-v-e150bd4a] {
+.method-tab.active[data-v-21112df8] {
+ color: #3b82f6;
+ border-bottom-color: #3b82f6;
+}
+.instructions-section[data-v-21112df8] {
margin-bottom: 1rem;
- color: #111827;
- font-size: 1.125rem;
- font-weight: 600;
}
-.instructions-text[data-v-e150bd4a] {
+.instructions-text[data-v-21112df8] {
margin-bottom: 1.5rem;
color: #6b7280;
line-height: 1.6;
}
-.command-group[data-v-e150bd4a] {
- margin-bottom: 1.5rem;
-}
-.download-files-section[data-v-e150bd4a] {
- margin-bottom: 1.5rem;
+.step-group[data-v-21112df8] {
+ margin-bottom: 2rem;
}
-.download-files-section h5[data-v-e150bd4a] {
- margin-bottom: 0.75rem;
+.step-group h5[data-v-21112df8] {
+ margin-bottom: 0.5rem;
color: #374151;
font-size: 1rem;
font-weight: 600;
}
-.download-block[data-v-e150bd4a] {
+.step-description[data-v-21112df8] {
+ margin-bottom: 0.75rem;
+ color: #6b7280;
+ font-size: 0.875rem;
+}
+.download-block[data-v-21112df8] {
background: #f0f9ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
padding: 1rem;
+ margin-bottom: 0.75rem;
}
-.download-text[data-v-e150bd4a] {
- margin-bottom: 1rem;
- color: #374151;
+.download-actions[data-v-21112df8] {
+ display: flex;
+ gap: 1rem;
+}
+.download-btn[data-v-21112df8] {
+ padding: 0.75rem 1.5rem;
+ background: #6b7280;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ cursor: pointer;
font-size: 0.875rem;
+ font-weight: 500;
+ transition: background-color 0.2s;
}
-.download-text code[data-v-e150bd4a] {
- font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', monospace;
- background: #e5e7eb;
- padding: 0.125rem 0.375rem;
- border-radius: 4px;
- font-size: 0.8125rem;
+.download-btn[data-v-21112df8]:hover {
+ background: #4b5563;
}
-.command-group[data-v-e150bd4a] {
- margin-bottom: 1.5rem;
+.download-btn.primary[data-v-21112df8] {
+ background: #3b82f6;
}
-.command-group h5[data-v-e150bd4a] {
- margin-bottom: 0.75rem;
- color: #374151;
- font-size: 1rem;
- font-weight: 600;
+.download-btn.primary[data-v-21112df8]:hover {
+ background: #2563eb;
}
-.command-block[data-v-e150bd4a] {
+.command-block[data-v-21112df8] {
display: flex;
align-items: center;
background: #f8fafc;
@@ -311,7 +338,7 @@
padding: 1rem;
gap: 1rem;
}
-.command-block code[data-v-e150bd4a] {
+.command-block code[data-v-21112df8] {
flex: 1;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', monospace;
font-size: 0.875rem;
@@ -319,7 +346,7 @@
background: none;
word-break: break-all;
}
-.copy-cmd-btn[data-v-e150bd4a] {
+.copy-cmd-btn[data-v-21112df8] {
padding: 0.5rem 1rem;
background: #3b82f6;
color: white;
@@ -331,56 +358,156 @@
transition: background-color 0.2s;
flex-shrink: 0;
}
-.copy-cmd-btn[data-v-e150bd4a]:hover {
+.copy-cmd-btn[data-v-21112df8]:hover {
background: #2563eb;
}
-.alternative-section[data-v-e150bd4a] {
- margin-top: 2rem;
- padding-top: 1.5rem;
- border-top: 1px solid #e5e7eb;
+
+/* One-Click tab styles */
+.security-warning[data-v-21112df8] {
+ background: #fffbeb;
+ border: 1px solid #fcd34d;
+ border-radius: 8px;
+ padding: 1rem;
+ margin-bottom: 1.5rem;
+ color: #92400e;
+ font-size: 0.875rem;
+ line-height: 1.5;
}
-.alternative-section details[data-v-e150bd4a] {
- cursor: pointer;
+.upload-block[data-v-21112df8] {
+ background: #f9fafb;
+ border: 2px dashed #d1d5db;
+ border-radius: 8px;
+ padding: 1.5rem;
+ text-align: center;
+}
+.file-input[data-v-21112df8] {
+ margin-bottom: 0.5rem;
}
-.alternative-section summary[data-v-e150bd4a] {
+.file-info[data-v-21112df8] {
+ margin-top: 0.5rem;
+ color: #059669;
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+.apply-btn[data-v-21112df8] {
+ padding: 0.875rem 2rem;
+ background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 1rem;
font-weight: 600;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+.apply-btn[data-v-21112df8]:hover:not(:disabled) {
+ background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
+ transform: translateY(-1px);
+}
+.apply-btn[data-v-21112df8]:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+.spinner[data-v-21112df8] {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-radius: 50%;
+ border-top-color: white;
+ animation: spin-21112df8 0.8s linear infinite;
+}
+@keyframes spin-21112df8 {
+to { transform: rotate(360deg);
+}
+}
+.status-message[data-v-21112df8] {
+ padding: 1rem;
+ border-radius: 8px;
+ font-size: 0.9rem;
+ margin-top: 1rem;
+}
+.status-message.success[data-v-21112df8] {
+ background: #ecfdf5;
+ border: 1px solid #a7f3d0;
+ color: #065f46;
+}
+.status-message.error[data-v-21112df8] {
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #991b1b;
+}
+
+/* Already Connected tab styles */
+.status-check[data-v-21112df8] {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 2rem;
+ justify-content: center;
color: #6b7280;
- padding: 0.5rem 0;
- outline: none;
}
-.manual-setup[data-v-e150bd4a] {
- padding: 1rem 0;
+.status-check .spinner[data-v-21112df8] {
+ border-color: rgba(59, 130, 246, 0.3);
+ border-top-color: #3b82f6;
}
-.manual-setup h5[data-v-e150bd4a] {
- margin-bottom: 1rem;
- color: #374151;
+.connected-badge[data-v-21112df8] {
+ background: #ecfdf5;
+ border: 1px solid #a7f3d0;
+ color: #065f46;
+ padding: 0.75rem 1rem;
+ border-radius: 8px;
font-weight: 600;
+ font-size: 1rem;
+ margin-bottom: 1rem;
}
-.download-actions[data-v-e150bd4a] {
- display: flex;
- gap: 1rem;
+.not-connected-badge[data-v-21112df8] {
+ background: #f0f9ff;
+ border: 1px solid #bfdbfe;
+ color: #1e40af;
+ padding: 0.75rem 1rem;
+ border-radius: 8px;
+ font-weight: 600;
+ font-size: 1rem;
+ margin-bottom: 1rem;
}
-.download-btn[data-v-e150bd4a] {
- padding: 0.75rem 1.5rem;
- background: #6b7280;
- color: white;
- border: none;
+.exports-list[data-v-21112df8] {
+ background: #f9fafb;
+ border: 1px solid #e5e7eb;
border-radius: 8px;
- cursor: pointer;
+ padding: 1rem;
+ margin-bottom: 1.5rem;
+}
+.exports-list h5[data-v-21112df8] {
+ margin: 0 0 0.5rem 0;
+ color: #374151;
+ font-size: 0.9rem;
+}
+.exports-list ul[data-v-21112df8] {
+ margin: 0;
+ padding-left: 1.5rem;
+}
+.exports-list li[data-v-21112df8] {
+ color: #6b7280;
font-size: 0.875rem;
- font-weight: 500;
- transition: background-color 0.2s;
+ font-family: 'SF Mono', 'Monaco', monospace;
+ padding: 0.25rem 0;
}
-.download-btn[data-v-e150bd4a]:hover {
- background: #4b5563;
+.method-suggestions[data-v-21112df8] {
+ padding-left: 1.5rem;
+ color: #6b7280;
+ line-height: 2;
}
-.binding-footer[data-v-e150bd4a] {
+.binding-footer[data-v-21112df8] {
padding: 1.5rem 2rem;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
text-align: right;
}
-.ok-btn[data-v-e150bd4a] {
+.ok-btn[data-v-21112df8] {
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
@@ -390,7 +517,7 @@
font-weight: 600;
transition: all 0.2s;
}
-.ok-btn[data-v-e150bd4a]:hover {
+.ok-btn[data-v-21112df8]:hover {
background: linear-gradient(135deg, #059669 0%, #047857 100%);
transform: translateY(-1px);
}
@@ -739,6 +866,169 @@ to { transform: translateY(0) scale(1); opacity: 1;
transform: none;
}
+.modal-overlay[data-v-4c7f1934] {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+}
+.modal-container[data-v-4c7f1934] {
+ background: white;
+ border-radius: 12px;
+ width: 560px;
+ max-width: 90vw;
+ max-height: 80vh;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+}
+.modal-header[data-v-4c7f1934] {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1.25rem 1.5rem;
+ border-bottom: 1px solid #e2e8f0;
+}
+.modal-header h3[data-v-4c7f1934] {
+ margin: 0;
+ font-size: 1.25rem;
+ color: #1e293b;
+}
+.close-btn[data-v-4c7f1934] {
+ background: none;
+ border: none;
+ font-size: 1.5rem;
+ color: #64748b;
+ cursor: pointer;
+ line-height: 1;
+}
+.modal-body[data-v-4c7f1934] {
+ padding: 1.5rem;
+ overflow-y: auto;
+ flex: 1;
+}
+.detail-section[data-v-4c7f1934] {
+ margin-bottom: 1.25rem;
+}
+.detail-section[data-v-4c7f1934]:last-child {
+ margin-bottom: 0;
+}
+.detail-section h4[data-v-4c7f1934] {
+ margin: 0 0 0.5rem;
+ font-size: 0.875rem;
+ color: #475569;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+.tag-list[data-v-4c7f1934] {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+}
+.tag[data-v-4c7f1934] {
+ padding: 0.25rem 0.625rem;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ font-family: 'SF Mono', Monaco, monospace;
+ background: #f1f5f9;
+ color: #334155;
+ border: 1px solid #e2e8f0;
+}
+.permission-tag[data-v-4c7f1934] {
+ background: #fef3c7;
+ border-color: #fbbf24;
+ color: #92400e;
+}
+.export-tag[data-v-4c7f1934] {
+ background: #ecfdf5;
+ border-color: #6ee7b7;
+ color: #065f46;
+}
+.empty-note[data-v-4c7f1934] {
+ color: #94a3b8;
+ font-size: 0.875rem;
+ font-style: italic;
+ margin: 0;
+}
+.exports-section[data-v-4c7f1934] {
+ border-top: 1px solid #e2e8f0;
+ padding-top: 1.25rem;
+}
+.connection-info[data-v-4c7f1934] {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 0.625rem;
+}
+.connected-badge[data-v-4c7f1934] {
+ padding: 0.2rem 0.5rem;
+ border-radius: 4px;
+ font-weight: 600;
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ background: #22c55e;
+ color: white;
+}
+.ns-label[data-v-4c7f1934] {
+ font-size: 0.8rem;
+ color: #475569;
+}
+.status-loading[data-v-4c7f1934] {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: #64748b;
+ font-size: 0.875rem;
+}
+.spinner[data-v-4c7f1934] {
+ display: inline-block;
+ width: 14px;
+ height: 14px;
+ border: 2px solid #e2e8f0;
+ border-top-color: #3b82f6;
+ border-radius: 50%;
+ animation: spin-4c7f1934 0.6s linear infinite;
+}
+@keyframes spin-4c7f1934 {
+to { transform: rotate(360deg);
+}
+}
+.modal-footer[data-v-4c7f1934] {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.75rem;
+ padding: 1rem 1.5rem;
+ border-top: 1px solid #e2e8f0;
+}
+.secondary-btn[data-v-4c7f1934] {
+ padding: 0.5rem 1rem;
+ border-radius: 6px;
+ border: 1px solid #cbd5e1;
+ background: white;
+ color: #475569;
+ cursor: pointer;
+ font-size: 0.875rem;
+}
+.secondary-btn[data-v-4c7f1934]:hover {
+ background: #f8fafc;
+}
+.primary-btn[data-v-4c7f1934] {
+ padding: 0.5rem 1.25rem;
+ border-radius: 6px;
+ border: none;
+ background: #3b82f6;
+ color: white;
+ cursor: pointer;
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+.primary-btn[data-v-4c7f1934]:hover {
+ background: #2563eb;
+}
+
.modal-overlay[data-v-5efb8bd4] {
position: fixed;
top: 0;
@@ -915,30 +1205,30 @@ to { transform: translateY(0) scale(1); opacity: 1;
transform: translateY(-1px);
}
-.resources[data-v-2bef16ba] {
+.resources[data-v-1cd8a497] {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
}
-.header-section[data-v-2bef16ba] {
+.header-section[data-v-1cd8a497] {
margin-bottom: 2rem;
}
-.resources h2[data-v-2bef16ba] {
+.resources h2[data-v-1cd8a497] {
color: #1e293b;
margin-bottom: 1rem;
font-size: 1.875rem;
font-weight: 600;
font-family: inherit;
}
-.resources h3[data-v-2bef16ba] {
+.resources h3[data-v-1cd8a497] {
color: #334155;
font-size: 1.25rem;
font-weight: 600;
font-family: inherit;
margin: 0;
}
-.cli-indicator[data-v-2bef16ba] {
+.cli-indicator[data-v-1cd8a497] {
background-color: #dbeafe;
border: 1px solid #3b82f6;
border-radius: 8px;
@@ -950,17 +1240,17 @@ to { transform: translateY(0) scale(1); opacity: 1;
align-items: center;
gap: 0.5rem;
}
-.loading[data-v-2bef16ba] {
+.loading[data-v-1cd8a497] {
text-align: center;
padding: 2rem;
color: #666;
}
-.error[data-v-2bef16ba] {
+.error[data-v-1cd8a497] {
text-align: center;
padding: 2rem;
color: #dc3545;
}
-.retry-btn[data-v-2bef16ba] {
+.retry-btn[data-v-1cd8a497] {
padding: 0.5rem 1rem;
background-color: #007bff;
color: white;
@@ -969,32 +1259,32 @@ to { transform: translateY(0) scale(1); opacity: 1;
cursor: pointer;
margin-top: 1rem;
}
-.retry-btn[data-v-2bef16ba]:hover {
+.retry-btn[data-v-1cd8a497]:hover {
background-color: #0056b3;
}
-.resources-container[data-v-2bef16ba] {
+.resources-container[data-v-1cd8a497] {
display: grid;
gap: 3rem;
}
-.templates-section[data-v-2bef16ba],
-.collections-section[data-v-2bef16ba] {
+.templates-section[data-v-1cd8a497],
+.collections-section[data-v-1cd8a497] {
background: #f8fafc;
padding: 1.5rem;
border-radius: 12px;
border: 1px solid #e2e8f0;
}
-.no-resources[data-v-2bef16ba] {
+.no-resources[data-v-1cd8a497] {
text-align: center;
color: #666;
padding: 1rem;
}
-.section-header[data-v-2bef16ba] {
+.section-header[data-v-1cd8a497] {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
-.item-count[data-v-2bef16ba] {
+.item-count[data-v-1cd8a497] {
font-size: 0.9rem;
color: #666;
background: #e9ecef;
@@ -1002,7 +1292,7 @@ to { transform: translateY(0) scale(1); opacity: 1;
border-radius: 12px;
font-weight: 500;
}
-.no-resources[data-v-2bef16ba] {
+.no-resources[data-v-1cd8a497] {
text-align: center;
padding: 3rem 2rem;
color: #6b7280;
@@ -1010,26 +1300,26 @@ to { transform: translateY(0) scale(1); opacity: 1;
border-radius: 12px;
border: 2px dashed #d1d5db;
}
-.no-resources-icon[data-v-2bef16ba] {
+.no-resources-icon[data-v-1cd8a497] {
margin-bottom: 1rem;
opacity: 0.4;
color: #9ca3af;
}
-.no-resources h4[data-v-2bef16ba] {
+.no-resources h4[data-v-1cd8a497] {
margin: 0 0 0.5rem 0;
color: #374151;
font-weight: 600;
}
-.no-resources p[data-v-2bef16ba] {
+.no-resources p[data-v-1cd8a497] {
margin: 0;
font-size: 0.9rem;
}
-.resource-grid[data-v-2bef16ba] {
+.resource-grid[data-v-1cd8a497] {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
}
-.template-card[data-v-2bef16ba], .collection-card[data-v-2bef16ba] {
+.template-card[data-v-1cd8a497], .collection-card[data-v-1cd8a497] {
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
@@ -1038,27 +1328,27 @@ to { transform: translateY(0) scale(1); opacity: 1;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
-.template-card[data-v-2bef16ba]:hover, .collection-card[data-v-2bef16ba]:hover {
+.template-card[data-v-1cd8a497]:hover, .collection-card[data-v-1cd8a497]:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
border-color: #cbd5e1;
}
-.card-header[data-v-2bef16ba] {
+.card-header[data-v-1cd8a497] {
padding: 1.5rem 1.5rem 1rem 1.5rem;
border-bottom: 1px solid #f1f5f9;
}
-.card-title[data-v-2bef16ba] {
+.card-title[data-v-1cd8a497] {
margin: 0 0 0.75rem 0;
color: #1e293b;
font-size: 1.125rem;
font-weight: 600;
}
-.card-badges[data-v-2bef16ba] {
+.card-badges[data-v-1cd8a497] {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
-.badge[data-v-2bef16ba] {
+.badge[data-v-1cd8a497] {
font-size: 0.75rem;
padding: 0.25rem 0.75rem;
border-radius: 12px;
@@ -1066,42 +1356,42 @@ to { transform: translateY(0) scale(1); opacity: 1;
display: inline-flex;
align-items: center;
}
-.resources-badge[data-v-2bef16ba] {
+.resources-badge[data-v-1cd8a497] {
background: #dbeafe;
color: #1d4ed8;
}
-.permissions-badge[data-v-2bef16ba] {
+.permissions-badge[data-v-1cd8a497] {
background: #fef3c7;
color: #d97706;
}
-.namespaces-badge[data-v-2bef16ba] {
+.namespaces-badge[data-v-1cd8a497] {
background: #d1fae5;
color: #047857;
}
-.card-content[data-v-2bef16ba] {
+.card-content[data-v-1cd8a497] {
padding: 1rem 1.5rem;
}
-.card-description[data-v-2bef16ba] {
+.card-description[data-v-1cd8a497] {
color: #64748b;
margin: 0 0 1rem 0;
line-height: 1.5;
font-size: 0.9rem;
}
-.card-preview[data-v-2bef16ba], .collection-templates[data-v-2bef16ba] {
+.card-preview[data-v-1cd8a497], .collection-templates[data-v-1cd8a497] {
margin-top: 1rem;
}
-.card-preview strong[data-v-2bef16ba], .collection-templates strong[data-v-2bef16ba] {
+.card-preview strong[data-v-1cd8a497], .collection-templates strong[data-v-1cd8a497] {
color: #374151;
font-size: 0.875rem;
display: block;
margin-bottom: 0.5rem;
}
-.resource-preview[data-v-2bef16ba], .template-list[data-v-2bef16ba] {
+.resource-preview[data-v-1cd8a497], .template-list[data-v-1cd8a497] {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
-.resource-tag[data-v-2bef16ba], .template-tag[data-v-2bef16ba] {
+.resource-tag[data-v-1cd8a497], .template-tag[data-v-1cd8a497] {
font-size: 0.75rem;
background: #f1f5f9;
color: #475569;
@@ -1109,12 +1399,12 @@ to { transform: translateY(0) scale(1); opacity: 1;
border-radius: 6px;
border: 1px solid #e2e8f0;
}
-.more-indicator[data-v-2bef16ba] {
+.more-indicator[data-v-1cd8a497] {
font-size: 0.75rem;
color: #6b7280;
font-style: italic;
}
-.card-actions[data-v-2bef16ba] {
+.card-actions[data-v-1cd8a497] {
padding: 1rem 1.5rem;
background: #fafbfc;
border-top: 1px solid #f1f5f9;
@@ -1122,7 +1412,7 @@ to { transform: translateY(0) scale(1); opacity: 1;
gap: 0.75rem;
justify-content: flex-end;
}
-.details-btn[data-v-2bef16ba], .bind-btn[data-v-2bef16ba] {
+.details-btn[data-v-1cd8a497], .bind-btn[data-v-1cd8a497] {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
@@ -1131,21 +1421,21 @@ to { transform: translateY(0) scale(1); opacity: 1;
font-size: 0.875rem;
transition: all 0.2s ease;
}
-.details-btn[data-v-2bef16ba] {
+.details-btn[data-v-1cd8a497] {
background: #f8fafc;
color: #475569;
border: 1px solid #e2e8f0;
}
-.details-btn[data-v-2bef16ba]:hover {
+.details-btn[data-v-1cd8a497]:hover {
background: #f1f5f9;
border-color: #cbd5e1;
}
-.bind-btn[data-v-2bef16ba] {
+.bind-btn[data-v-1cd8a497] {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
border: 1px solid transparent;
}
-.bind-btn[data-v-2bef16ba]:hover {
+.bind-btn[data-v-1cd8a497]:hover {
background: linear-gradient(135deg, #059669 0%, #047857 100%);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(16, 185, 129, 0.2);
diff --git a/web/dist/assets/index.BSGySfUQ.js b/web/dist/assets/index.BBkPMOmG.js
similarity index 94%
rename from web/dist/assets/index.BSGySfUQ.js
rename to web/dist/assets/index.BBkPMOmG.js
index 0593afc82..8d39f3156 100644
--- a/web/dist/assets/index.BSGySfUQ.js
+++ b/web/dist/assets/index.BBkPMOmG.js
@@ -9696,27 +9696,27 @@ var AuthService = class {
var authService = new AuthService();
//#endregion
//#region src/App.vue?vue&type=script&setup=true&lang.ts
-var _hoisted_1$4 = { id: "app" };
-var _hoisted_2$4 = { class: "header" };
-var _hoisted_3$4 = { class: "header-content" };
-var _hoisted_4$4 = {
+var _hoisted_1$5 = { id: "app" };
+var _hoisted_2$5 = { class: "header" };
+var _hoisted_3$5 = { class: "header-content" };
+var _hoisted_4$5 = {
key: 0,
class: "user-section"
};
-var _hoisted_5$4 = { class: "main" };
-var _hoisted_6$4 = {
+var _hoisted_5$5 = { class: "main" };
+var _hoisted_6$5 = {
key: 0,
class: "auth-placeholder"
};
-var _hoisted_7$4 = {
+var _hoisted_7$5 = {
key: 0,
class: "auth-error"
};
-var _hoisted_8$3 = { class: "error-content" };
-var _hoisted_9$3 = { key: 1 };
-var _hoisted_10$3 = ["disabled"];
-var _hoisted_11$3 = { key: 0 };
-var _hoisted_12$3 = { key: 1 };
+var _hoisted_8$4 = { class: "error-content" };
+var _hoisted_9$4 = { key: 1 };
+var _hoisted_10$4 = ["disabled"];
+var _hoisted_11$4 = { key: 0 };
+var _hoisted_12$4 = { key: 1 };
var App_vue_vue_type_script_setup_true_lang_default = /* @__PURE__ */ defineComponent({
__name: "App",
setup(__props) {
@@ -9794,7 +9794,7 @@ var App_vue_vue_type_script_setup_true_lang_default = /* @__PURE__ */ defineComp
});
return (_ctx, _cache) => {
const _component_router_view = resolveComponent("router-view");
- return openBlock(), createElementBlock("div", _hoisted_1$4, [createBaseVNode("header", _hoisted_2$4, [createBaseVNode("div", _hoisted_3$4, [_cache[2] || (_cache[2] = createStaticVNode("
", 1)), authStatus.value.isAuthenticated ? (openBlock(), createElementBlock("div", _hoisted_4$4, [_cache[1] || (_cache[1] = createBaseVNode("div", { class: "user-info" }, [createBaseVNode("div", { class: "status-indicator" }), createBaseVNode("span", { class: "welcome-text" }, "Connected")], -1)), createBaseVNode("button", {
+ return openBlock(), createElementBlock("div", _hoisted_1$5, [createBaseVNode("header", _hoisted_2$5, [createBaseVNode("div", _hoisted_3$5, [_cache[2] || (_cache[2] = createStaticVNode("", 1)), authStatus.value.isAuthenticated ? (openBlock(), createElementBlock("div", _hoisted_4$5, [_cache[1] || (_cache[1] = createBaseVNode("div", { class: "user-info" }, [createBaseVNode("div", { class: "status-indicator" }), createBaseVNode("span", { class: "welcome-text" }, "Connected")], -1)), createBaseVNode("button", {
onClick: logout,
class: "logout-btn"
}, [..._cache[0] || (_cache[0] = [createBaseVNode("svg", {
@@ -9825,14 +9825,14 @@ var App_vue_vue_type_script_setup_true_lang_default = /* @__PURE__ */ defineComp
"stroke-linecap": "round",
"stroke-linejoin": "round"
})
- ], -1), createTextVNode(" Sign out ", -1)])])])) : createCommentVNode("", true)])]), createBaseVNode("main", _hoisted_5$4, [!authStatus.value.isAuthenticated ? (openBlock(), createElementBlock("div", _hoisted_6$4, [
+ ], -1), createTextVNode(" Sign out ", -1)])])])) : createCommentVNode("", true)])]), createBaseVNode("main", _hoisted_5$5, [!authStatus.value.isAuthenticated ? (openBlock(), createElementBlock("div", _hoisted_6$5, [
_cache[5] || (_cache[5] = createBaseVNode("h2", null, "Authentication Required", -1)),
- authStatus.value.error ? (openBlock(), createElementBlock("div", _hoisted_7$4, [_cache[4] || (_cache[4] = createBaseVNode("div", { class: "error-icon" }, "⚠️", -1)), createBaseVNode("div", _hoisted_8$3, [_cache[3] || (_cache[3] = createBaseVNode("h3", null, "Authentication Error", -1)), createBaseVNode("p", null, toDisplayString(authStatus.value.error), 1)])])) : (openBlock(), createElementBlock("p", _hoisted_9$3, "Please authenticate to access resources.")),
+ authStatus.value.error ? (openBlock(), createElementBlock("div", _hoisted_7$5, [_cache[4] || (_cache[4] = createBaseVNode("div", { class: "error-icon" }, "⚠️", -1)), createBaseVNode("div", _hoisted_8$4, [_cache[3] || (_cache[3] = createBaseVNode("h3", null, "Authentication Error", -1)), createBaseVNode("p", null, toDisplayString(authStatus.value.error), 1)])])) : (openBlock(), createElementBlock("p", _hoisted_9$4, "Please authenticate to access resources.")),
createBaseVNode("button", {
onClick: authenticate,
class: "auth-btn",
disabled: authStatus.value.loading
- }, [authStatus.value.loading ? (openBlock(), createElementBlock("span", _hoisted_11$3, "Authenticating...")) : (openBlock(), createElementBlock("span", _hoisted_12$3, toDisplayString(authStatus.value.error ? "Try Again" : "Authenticate"), 1))], 8, _hoisted_10$3)
+ }, [authStatus.value.loading ? (openBlock(), createElementBlock("span", _hoisted_11$4, "Authenticating...")) : (openBlock(), createElementBlock("span", _hoisted_12$4, toDisplayString(authStatus.value.error ? "Try Again" : "Authenticate"), 1))], 8, _hoisted_10$4)
])) : (openBlock(), createBlock(_component_router_view, {
key: 1,
"auth-status": authStatus.value
@@ -9852,19 +9852,54 @@ var _plugin_vue_export_helper_default = (sfc, props) => {
var App_default = /* @__PURE__ */ _plugin_vue_export_helper_default(App_vue_vue_type_script_setup_true_lang_default, [["__scopeId", "data-v-0b535f4a"]]);
//#endregion
//#region src/components/BindingResult.vue?vue&type=script&setup=true&lang.ts
-var _hoisted_1$3 = { class: "binding-content" };
-var _hoisted_2$3 = { class: "binding-info" };
-var _hoisted_3$3 = { class: "instructions-section" };
-var _hoisted_4$3 = { class: "command-group" };
-var _hoisted_5$3 = { class: "command-block" };
-var _hoisted_6$3 = { class: "command-group" };
-var _hoisted_7$3 = { class: "command-block" };
-var _hoisted_8$2 = { class: "command-group" };
-var _hoisted_9$2 = { class: "command-block" };
-var _hoisted_10$2 = { class: "alternative-section" };
-var _hoisted_11$2 = { class: "manual-setup" };
-var _hoisted_12$2 = { class: "command-group" };
-var _hoisted_13$2 = { class: "command-block" };
+var _hoisted_1$4 = { class: "binding-header" };
+var _hoisted_2$4 = { class: "binding-content" };
+var _hoisted_3$4 = { class: "method-tabs" };
+var _hoisted_4$4 = {
+ key: 0,
+ class: "instructions-section"
+};
+var _hoisted_5$4 = { class: "step-group" };
+var _hoisted_6$4 = { class: "upload-block" };
+var _hoisted_7$4 = ["disabled"];
+var _hoisted_8$3 = {
+ key: 0,
+ class: "file-info"
+};
+var _hoisted_9$3 = { class: "step-group" };
+var _hoisted_10$3 = ["disabled"];
+var _hoisted_11$3 = {
+ key: 0,
+ class: "spinner"
+};
+var _hoisted_12$3 = {
+ key: 0,
+ class: "status-message success"
+};
+var _hoisted_13$3 = {
+ key: 1,
+ class: "status-message error"
+};
+var _hoisted_14$3 = {
+ key: 1,
+ class: "instructions-section"
+};
+var _hoisted_15$3 = { class: "step-group" };
+var _hoisted_16$3 = { class: "command-block" };
+var _hoisted_17$3 = { class: "step-group" };
+var _hoisted_18$2 = { class: "command-block" };
+var _hoisted_19$2 = { class: "step-group" };
+var _hoisted_20$2 = { class: "command-block" };
+var _hoisted_21$2 = {
+ key: 2,
+ class: "instructions-section"
+};
+var _hoisted_22$2 = { class: "step-group" };
+var _hoisted_23$2 = { class: "command-block" };
+var _hoisted_24$2 = { class: "step-group" };
+var _hoisted_25$2 = { class: "command-block" };
+var _hoisted_26$2 = { class: "step-group" };
+var _hoisted_27$2 = { class: "command-block" };
//#endregion
//#region src/components/BindingResult.vue
var BindingResult_default = /* @__PURE__ */ _plugin_vue_export_helper_default(/* @__PURE__ */ defineComponent({
@@ -9878,12 +9913,17 @@ var BindingResult_default = /* @__PURE__ */ _plugin_vue_export_helper_default(/*
setup(__props, { emit: __emit }) {
const props = __props;
const emit = __emit;
- const kubeconfigSecretName = computed(() => {
- return `kubeconfig-${props.templateName.toLowerCase().replace(/[^a-z0-9]/g, "")}-${Date.now().toString(36)}`;
- });
+ const activeMethod = /* @__PURE__ */ ref("oneclick");
+ const kubeconfigData = /* @__PURE__ */ ref(null);
+ const kubeconfigFileName = /* @__PURE__ */ ref("");
+ const applyStatus = /* @__PURE__ */ ref("idle");
+ const applyMessage = /* @__PURE__ */ ref("");
+ const kubeconfigFileInput = /* @__PURE__ */ ref(null);
const bindingName = computed(() => {
- var _props$bindingRespons;
- return ((_props$bindingRespons = props.bindingResponse.authentication) === null || _props$bindingRespons === void 0 || (_props$bindingRespons = _props$bindingRespons.oauth2CodeGrant) === null || _props$bindingRespons === void 0 ? void 0 : _props$bindingRespons.sessionID) || props.templateName;
+ return props.bindingResponse.bindingName || props.templateName;
+ });
+ const kubeconfigSecretName = computed(() => {
+ return `kubeconfig-${bindingName.value.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`;
});
const createSecretCommand = computed(() => {
return `kubectl create secret generic ${kubeconfigSecretName.value} --from-file=kubeconfig=./kubeconfig.yaml -n kube-bind`;
@@ -9891,11 +9931,57 @@ var BindingResult_default = /* @__PURE__ */ _plugin_vue_export_helper_default(/*
const bindCommand = computed(() => {
return `kubectl bind apiservice --remote-kubeconfig-namespace kube-bind --remote-kubeconfig-name ${kubeconfigSecretName.value} -f apiservice-export.yaml`;
});
+ const handleKubeconfigUpload = (event) => {
+ var _target$files;
+ const file = (_target$files = event.target.files) === null || _target$files === void 0 ? void 0 : _target$files[0];
+ if (!file) return;
+ kubeconfigFileName.value = file.name;
+ applyStatus.value = "idle";
+ applyMessage.value = "";
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ var _e$target;
+ const content = (_e$target = e.target) === null || _e$target === void 0 ? void 0 : _e$target.result;
+ kubeconfigData.value = btoa(content);
+ };
+ reader.readAsText(file);
+ };
+ const applyToConsumer = function() {
+ var _ref = _asyncToGenerator(function* () {
+ if (!kubeconfigData.value) return;
+ applyStatus.value = "loading";
+ applyMessage.value = "";
+ try {
+ const response = yield fetch("/api/apply-binding", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ consumerKubeconfig: kubeconfigData.value,
+ bindingName: bindingName.value
+ })
+ });
+ if (!response.ok) {
+ const errorData = yield response.json().catch(() => null);
+ throw new Error((errorData === null || errorData === void 0 ? void 0 : errorData.message) || `HTTP ${response.status}`);
+ }
+ const result = yield response.json();
+ applyStatus.value = "success";
+ applyMessage.value = result.message;
+ if (result.konnectorDeployed) applyMessage.value += " (konnector was newly deployed)";
+ } catch (error) {
+ applyStatus.value = "error";
+ applyMessage.value = error.message || "Failed to apply binding";
+ }
+ });
+ return function applyToConsumer() {
+ return _ref.apply(this, arguments);
+ };
+ }();
const closeModal = () => {
emit("close");
};
const copyCommand = function() {
- var _ref = _asyncToGenerator(function* (command) {
+ var _ref2 = _asyncToGenerator(function* (command) {
try {
yield navigator.clipboard.writeText(command);
} catch (err) {
@@ -9909,93 +9995,85 @@ var BindingResult_default = /* @__PURE__ */ _plugin_vue_export_helper_default(/*
}
});
return function copyCommand(_x) {
- return _ref.apply(this, arguments);
+ return _ref2.apply(this, arguments);
};
}();
- const downloadKubeconfig = () => {
+ const triggerDownload = (content, filename, type = "text/yaml") => {
+ const blob = new Blob([content], { type });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
+ const decodedKubeconfig = computed(() => {
try {
- const decodedKubeconfig = atob(props.bindingResponse.kubeconfig);
- const blob = new Blob([decodedKubeconfig], { type: "text/yaml" });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = "kubeconfig.yaml";
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- } catch (error) {
- console.error("Failed to decode kubeconfig:", error);
- const blob = new Blob([props.bindingResponse.kubeconfig], { type: "text/yaml" });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = "kubeconfig.yaml";
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
+ return atob(props.bindingResponse.kubeconfig);
+ } catch (_unused) {
+ return props.bindingResponse.kubeconfig;
}
+ });
+ const bindingBundleYaml = computed(() => {
+ const kubeconfigBase64 = props.bindingResponse.kubeconfig;
+ const secretName = kubeconfigSecretName.value;
+ return `apiVersion: v1
+kind: Namespace
+metadata:
+ name: kube-bind
+---
+apiVersion: v1
+kind: Secret
+metadata:
+ name: ${secretName}
+ namespace: kube-bind
+type: Opaque
+data:
+ kubeconfig: ${kubeconfigBase64}
+---
+apiVersion: kube-bind.io/v1alpha2
+kind: APIServiceBindingBundle
+metadata:
+ name: ${bindingName.value}
+spec:
+ kubeconfigSecretRef:
+ name: ${secretName}
+ namespace: kube-bind
+ key: kubeconfig
+`;
+ });
+ const downloadBindingBundle = () => {
+ triggerDownload(bindingBundleYaml.value, "binding-bundle.yaml");
+ };
+ const downloadKonnectorManifests = function() {
+ var _ref3 = _asyncToGenerator(function* () {
+ try {
+ const response = yield fetch("/api/konnector-manifests");
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ triggerDownload(yield response.text(), "konnector.yaml");
+ } catch (error) {
+ console.error("Failed to fetch konnector manifests:", error);
+ }
+ });
+ return function downloadKonnectorManifests() {
+ return _ref3.apply(this, arguments);
+ };
+ }();
+ const downloadKubeconfig = () => {
+ triggerDownload(decodedKubeconfig.value, "kubeconfig.yaml");
};
const downloadAPIRequests = () => {
try {
- const apiRequestsYaml = props.bindingResponse.requests.map((req) => {
+ triggerDownload(props.bindingResponse.requests.map((req) => {
if (typeof req === "string") return req.trim();
- else return formatObjectAsYaml(req);
- }).join("\n---\n");
- const blob = new Blob([apiRequestsYaml], { type: "text/yaml" });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = "apiservice-export.yaml";
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
+ else return JSON.stringify(req, null, 2);
+ }).join("\n---\n"), "apiservice-export.yaml");
} catch (error) {
- console.error("Failed to format API requests as YAML:", error);
- const apiRequestsJson = JSON.stringify(props.bindingResponse.requests, null, 2);
- const blob = new Blob([apiRequestsJson], { type: "application/json" });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = "apiservice-export.json";
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
- }
- };
- const formatObjectAsYaml = (obj, indent = 0) => {
- const spaces = " ".repeat(indent);
- if (obj === null || obj === void 0) return "";
- if (typeof obj === "string") {
- if (obj.includes("\n")) return `|\n${spaces} ${obj.replace(/\n/g, `\n${spaces} `)}`;
- if (obj.match(/^(true|false|null|\d+)$/i) || obj.includes(":") || obj.includes("#")) return `"${obj}"`;
- return obj;
+ console.error("Failed to format API requests:", error);
+ triggerDownload(JSON.stringify(props.bindingResponse.requests, null, 2), "apiservice-export.json", "application/json");
}
- if (typeof obj === "number" || typeof obj === "boolean") return String(obj);
- if (Array.isArray(obj)) {
- if (obj.length === 0) return "[]";
- return obj.map((item) => {
- if (typeof item === "object" && item !== null && !Array.isArray(item)) return `${spaces}- ${formatObjectAsYaml(item, indent + 2).replace(/^\s*/, "")}`;
- else return `${spaces}- ${item}`;
- }).join("\n");
- }
- if (typeof obj === "object") return Object.entries(obj).map(([key, value]) => {
- if (value === null || value === void 0) return `${spaces}${key}:`;
- if (Array.isArray(value)) {
- if (value.length === 0) return `${spaces}${key}: []`;
- return `${spaces}${key}:\n${formatObjectAsYaml(value, indent + 2)}`;
- }
- if (typeof value === "object") {
- const formattedObject = formatObjectAsYaml(value, indent + 2);
- if (formattedObject.trim() === "") return `${spaces}${key}:`;
- return `${spaces}${key}:\n${formattedObject}`;
- }
- return `${spaces}${key}: ${formatObjectAsYaml(value, 0)}`;
- }).join("\n");
- return String(obj);
};
return (_ctx, _cache) => {
return __props.show ? (openBlock(), createElementBlock("div", {
@@ -10004,46 +10082,109 @@ var BindingResult_default = /* @__PURE__ */ _plugin_vue_export_helper_default(/*
onClick: closeModal
}, [createBaseVNode("div", {
class: "binding-modal",
- onClick: _cache[4] || (_cache[4] = withModifiers(() => {}, ["stop"]))
+ onClick: _cache[9] || (_cache[9] = withModifiers(() => {}, ["stop"]))
}, [
- createBaseVNode("div", { class: "binding-header" }, [_cache[5] || (_cache[5] = createBaseVNode("h3", null, "Template Binding Successful", -1)), createBaseVNode("button", {
+ createBaseVNode("div", _hoisted_1$4, [createBaseVNode("h3", null, "Setup Binding: " + toDisplayString(__props.templateName), 1), createBaseVNode("button", {
onClick: closeModal,
class: "close-btn"
}, "×")]),
- createBaseVNode("div", _hoisted_1$3, [
- createBaseVNode("div", _hoisted_2$3, [
- _cache[9] || (_cache[9] = createBaseVNode("h4", null, "Binding Information", -1)),
- createBaseVNode("p", null, [_cache[6] || (_cache[6] = createBaseVNode("strong", null, "Template:", -1)), createTextVNode(" " + toDisplayString(__props.templateName), 1)]),
- createBaseVNode("p", null, [_cache[7] || (_cache[7] = createBaseVNode("strong", null, "Binding Name:", -1)), createTextVNode(" " + toDisplayString(bindingName.value), 1)]),
- createBaseVNode("p", null, [_cache[8] || (_cache[8] = createBaseVNode("strong", null, "Kubeconfig Secret:", -1)), createTextVNode(" " + toDisplayString(kubeconfigSecretName.value), 1)])
+ createBaseVNode("div", _hoisted_2$4, [
+ createBaseVNode("div", _hoisted_3$4, [
+ createBaseVNode("button", {
+ class: normalizeClass(["method-tab", { active: activeMethod.value === "oneclick" }]),
+ onClick: _cache[0] || (_cache[0] = ($event) => activeMethod.value = "oneclick")
+ }, " Provide Kubeconfig ", 2),
+ createBaseVNode("button", {
+ class: normalizeClass(["method-tab", { active: activeMethod.value === "bundle" }]),
+ onClick: _cache[1] || (_cache[1] = ($event) => activeMethod.value = "bundle")
+ }, " APIServiceBindingBundle ", 2),
+ createBaseVNode("button", {
+ class: normalizeClass(["method-tab", { active: activeMethod.value === "manual" }]),
+ onClick: _cache[2] || (_cache[2] = ($event) => activeMethod.value = "manual")
+ }, " Manual (CLI) ", 2)
]),
- createBaseVNode("div", _hoisted_3$3, [
- _cache[16] || (_cache[16] = createBaseVNode("h4", null, "Setup Instructions", -1)),
- _cache[17] || (_cache[17] = createBaseVNode("p", { class: "instructions-text" }, " To complete the binding setup, first download the required files below, then execute the following commands in your local kubectl environment: ", -1)),
- createBaseVNode("div", { class: "download-files-section" }, [_cache[11] || (_cache[11] = createBaseVNode("h5", null, "1. Download required files", -1)), createBaseVNode("div", { class: "download-block" }, [_cache[10] || (_cache[10] = createBaseVNode("p", { class: "download-text" }, "Download and save both files in your current directory:", -1)), createBaseVNode("div", { class: "download-actions" }, [createBaseVNode("button", {
+ activeMethod.value === "oneclick" ? (openBlock(), createElementBlock("div", _hoisted_4$4, [
+ _cache[12] || (_cache[12] = createBaseVNode("p", { class: "instructions-text" }, " Upload your consumer cluster kubeconfig and we'll automatically deploy the konnector agent and configure the binding bundle for you. No CLI required. ", -1)),
+ _cache[13] || (_cache[13] = createBaseVNode("div", { class: "security-warning" }, [
+ createBaseVNode("strong", null, "⚠️ Security Note:"),
+ createTextVNode(" Your kubeconfig will be used transiently to apply resources to your consumer cluster and will "),
+ createBaseVNode("strong", null, "not"),
+ createTextVNode(" be stored. The provider backend needs cluster-admin level access to deploy the konnector and create binding resources. ")
+ ], -1)),
+ createBaseVNode("div", _hoisted_5$4, [
+ _cache[10] || (_cache[10] = createBaseVNode("h5", null, "Upload Consumer Kubeconfig", -1)),
+ _cache[11] || (_cache[11] = createBaseVNode("p", { class: "step-description" }, "Select the kubeconfig file for your consumer cluster.", -1)),
+ createBaseVNode("div", _hoisted_6$4, [createBaseVNode("input", {
+ type: "file",
+ ref_key: "kubeconfigFileInput",
+ ref: kubeconfigFileInput,
+ accept: ".yaml,.yml,.conf,.kubeconfig",
+ onChange: handleKubeconfigUpload,
+ class: "file-input",
+ disabled: applyStatus.value === "loading"
+ }, null, 40, _hoisted_7$4), kubeconfigFileName.value ? (openBlock(), createElementBlock("div", _hoisted_8$3, " 📄 " + toDisplayString(kubeconfigFileName.value), 1)) : createCommentVNode("", true)])
+ ]),
+ createBaseVNode("div", _hoisted_9$3, [createBaseVNode("button", {
+ onClick: applyToConsumer,
+ class: "apply-btn",
+ disabled: !kubeconfigData.value || applyStatus.value === "loading"
+ }, [applyStatus.value === "loading" ? (openBlock(), createElementBlock("span", _hoisted_11$3)) : createCommentVNode("", true), createTextVNode(" " + toDisplayString(applyStatus.value === "loading" ? "Applying..." : "Apply to Consumer Cluster"), 1)], 8, _hoisted_10$3)]),
+ applyStatus.value === "success" ? (openBlock(), createElementBlock("div", _hoisted_12$3, " ✅ " + toDisplayString(applyMessage.value), 1)) : createCommentVNode("", true),
+ applyStatus.value === "error" ? (openBlock(), createElementBlock("div", _hoisted_13$3, " ❌ " + toDisplayString(applyMessage.value), 1)) : createCommentVNode("", true)
+ ])) : createCommentVNode("", true),
+ activeMethod.value === "bundle" ? (openBlock(), createElementBlock("div", _hoisted_14$3, [
+ _cache[22] || (_cache[22] = createBaseVNode("p", { class: "instructions-text" }, " Download and apply these files to your consumer cluster. The APIServiceBindingBundle will automatically discover and bind services from the provider. ", -1)),
+ createBaseVNode("div", _hoisted_15$3, [
+ _cache[15] || (_cache[15] = createBaseVNode("h5", null, "1. Deploy the konnector agent (one-time)", -1)),
+ _cache[16] || (_cache[16] = createBaseVNode("p", { class: "step-description" }, "Skip this step if the konnector is already deployed on your consumer cluster.", -1)),
+ createBaseVNode("div", { class: "download-block" }, [createBaseVNode("div", { class: "download-actions" }, [createBaseVNode("button", {
+ onClick: downloadKonnectorManifests,
+ class: "download-btn primary"
+ }, "Download konnector.yaml")])]),
+ createBaseVNode("div", _hoisted_16$3, [_cache[14] || (_cache[14] = createBaseVNode("code", null, "kubectl apply -f konnector.yaml", -1)), createBaseVNode("button", {
+ onClick: _cache[3] || (_cache[3] = ($event) => copyCommand("kubectl apply -f konnector.yaml")),
+ class: "copy-cmd-btn"
+ }, "Copy")])
+ ]),
+ createBaseVNode("div", _hoisted_17$3, [
+ _cache[18] || (_cache[18] = createBaseVNode("h5", null, "2. Apply the binding bundle", -1)),
+ _cache[19] || (_cache[19] = createBaseVNode("p", { class: "step-description" }, "Creates the kubeconfig secret and an APIServiceBindingBundle that auto-discovers and binds all services.", -1)),
+ createBaseVNode("div", { class: "download-block" }, [createBaseVNode("div", { class: "download-actions" }, [createBaseVNode("button", {
+ onClick: downloadBindingBundle,
+ class: "download-btn primary"
+ }, "Download binding-bundle.yaml")])]),
+ createBaseVNode("div", _hoisted_18$2, [_cache[17] || (_cache[17] = createBaseVNode("code", null, "kubectl apply -f binding-bundle.yaml", -1)), createBaseVNode("button", {
+ onClick: _cache[4] || (_cache[4] = ($event) => copyCommand("kubectl apply -f binding-bundle.yaml")),
+ class: "copy-cmd-btn"
+ }, "Copy")])
+ ]),
+ createBaseVNode("div", _hoisted_19$2, [_cache[21] || (_cache[21] = createBaseVNode("h5", null, "3. Verify", -1)), createBaseVNode("div", _hoisted_20$2, [_cache[20] || (_cache[20] = createBaseVNode("code", null, "kubectl get apiservicebindingbundles,apiservicebindings", -1)), createBaseVNode("button", {
+ onClick: _cache[5] || (_cache[5] = ($event) => copyCommand("kubectl get apiservicebindingbundles,apiservicebindings")),
+ class: "copy-cmd-btn"
+ }, "Copy")])])
+ ])) : createCommentVNode("", true),
+ activeMethod.value === "manual" ? (openBlock(), createElementBlock("div", _hoisted_21$2, [
+ _cache[28] || (_cache[28] = createBaseVNode("p", { class: "instructions-text" }, " Download the required files and run the following commands on your consumer cluster. ", -1)),
+ createBaseVNode("div", { class: "step-group" }, [_cache[23] || (_cache[23] = createBaseVNode("h5", null, "1. Download required files", -1)), createBaseVNode("div", { class: "download-block" }, [createBaseVNode("div", { class: "download-actions" }, [createBaseVNode("button", {
onClick: downloadKubeconfig,
- class: "download-btn"
+ class: "download-btn primary"
}, "Download kubeconfig.yaml"), createBaseVNode("button", {
onClick: downloadAPIRequests,
- class: "download-btn"
+ class: "download-btn primary"
}, "Download apiservice-export.yaml")])])]),
- createBaseVNode("div", _hoisted_4$3, [_cache[13] || (_cache[13] = createBaseVNode("h5", null, "2. Create kube-bind namespace (if it doesn't exist)", -1)), createBaseVNode("div", _hoisted_5$3, [_cache[12] || (_cache[12] = createBaseVNode("code", null, "kubectl create namespace kube-bind --dry-run=client -o yaml | kubectl apply -f -", -1)), createBaseVNode("button", {
- onClick: _cache[0] || (_cache[0] = ($event) => copyCommand("kubectl create namespace kube-bind --dry-run=client -o yaml | kubectl apply -f -")),
+ createBaseVNode("div", _hoisted_22$2, [_cache[25] || (_cache[25] = createBaseVNode("h5", null, "2. Create kube-bind namespace", -1)), createBaseVNode("div", _hoisted_23$2, [_cache[24] || (_cache[24] = createBaseVNode("code", null, "kubectl create namespace kube-bind --dry-run=client -o yaml | kubectl apply -f -", -1)), createBaseVNode("button", {
+ onClick: _cache[6] || (_cache[6] = ($event) => copyCommand("kubectl create namespace kube-bind --dry-run=client -o yaml | kubectl apply -f -")),
class: "copy-cmd-btn"
}, "Copy")])]),
- createBaseVNode("div", _hoisted_6$3, [_cache[14] || (_cache[14] = createBaseVNode("h5", null, "3. Create kubeconfig secret", -1)), createBaseVNode("div", _hoisted_7$3, [createBaseVNode("code", null, toDisplayString(createSecretCommand.value), 1), createBaseVNode("button", {
- onClick: _cache[1] || (_cache[1] = ($event) => copyCommand(createSecretCommand.value)),
+ createBaseVNode("div", _hoisted_24$2, [_cache[26] || (_cache[26] = createBaseVNode("h5", null, "3. Create kubeconfig secret", -1)), createBaseVNode("div", _hoisted_25$2, [createBaseVNode("code", null, toDisplayString(createSecretCommand.value), 1), createBaseVNode("button", {
+ onClick: _cache[7] || (_cache[7] = ($event) => copyCommand(createSecretCommand.value)),
class: "copy-cmd-btn"
}, "Copy")])]),
- createBaseVNode("div", _hoisted_8$2, [_cache[15] || (_cache[15] = createBaseVNode("h5", null, "4. Bind the API service", -1)), createBaseVNode("div", _hoisted_9$2, [createBaseVNode("code", null, toDisplayString(bindCommand.value), 1), createBaseVNode("button", {
- onClick: _cache[2] || (_cache[2] = ($event) => copyCommand(bindCommand.value)),
+ createBaseVNode("div", _hoisted_26$2, [_cache[27] || (_cache[27] = createBaseVNode("h5", null, "4. Bind the API service", -1)), createBaseVNode("div", _hoisted_27$2, [createBaseVNode("code", null, toDisplayString(bindCommand.value), 1), createBaseVNode("button", {
+ onClick: _cache[8] || (_cache[8] = ($event) => copyCommand(bindCommand.value)),
class: "copy-cmd-btn"
}, "Copy")])])
- ]),
- createBaseVNode("div", _hoisted_10$2, [createBaseVNode("details", null, [_cache[19] || (_cache[19] = createBaseVNode("summary", null, "Alternative: Use stdin piping", -1)), createBaseVNode("div", _hoisted_11$2, [_cache[18] || (_cache[18] = createBaseVNode("h5", null, "For advanced users who prefer piping:", -1)), createBaseVNode("div", _hoisted_12$2, [createBaseVNode("div", _hoisted_13$2, [createBaseVNode("code", null, "cat apiservice-export.yaml | kubectl bind apiservice --remote-kubeconfig-namespace kube-bind --remote-kubeconfig-name " + toDisplayString(kubeconfigSecretName.value) + " -f -", 1), createBaseVNode("button", {
- onClick: _cache[3] || (_cache[3] = ($event) => copyCommand(`cat apiservice-export.yaml | kubectl bind apiservice --remote-kubeconfig-namespace kube-bind --remote-kubeconfig-name ${kubeconfigSecretName.value} -f -`)),
- class: "copy-cmd-btn"
- }, "Copy")])])])])])
+ ])) : createCommentVNode("", true)
]),
createBaseVNode("div", { class: "binding-footer" }, [createBaseVNode("button", {
onClick: closeModal,
@@ -10052,44 +10193,44 @@ var BindingResult_default = /* @__PURE__ */ _plugin_vue_export_helper_default(/*
])])) : createCommentVNode("", true);
};
}
-}), [["__scopeId", "data-v-e150bd4a"]]);
+}), [["__scopeId", "data-v-21112df8"]]);
//#endregion
//#region src/components/TemplateBindingModal.vue?vue&type=script&setup=true&lang.ts
-var _hoisted_1$2 = { class: "modal-header" };
-var _hoisted_2$2 = { class: "modal-content" };
-var _hoisted_3$2 = { class: "binding-name-section" };
-var _hoisted_4$2 = {
+var _hoisted_1$3 = { class: "modal-header" };
+var _hoisted_2$3 = { class: "modal-content" };
+var _hoisted_3$3 = { class: "binding-name-section" };
+var _hoisted_4$3 = {
key: 0,
class: "form-help"
};
-var _hoisted_5$2 = {
+var _hoisted_5$3 = {
key: 1,
class: "form-error"
};
-var _hoisted_6$2 = { class: "template-details" };
-var _hoisted_7$2 = {
+var _hoisted_6$3 = { class: "template-details" };
+var _hoisted_7$3 = {
key: 0,
class: "detail-section"
};
-var _hoisted_8$1 = { class: "description" };
-var _hoisted_9$1 = {
+var _hoisted_8$2 = { class: "description" };
+var _hoisted_9$2 = {
key: 1,
class: "detail-section"
};
-var _hoisted_10$1 = { class: "resource-list" };
-var _hoisted_11$1 = { class: "resource-name" };
-var _hoisted_12$1 = { class: "resource-group" };
-var _hoisted_13$1 = {
+var _hoisted_10$2 = { class: "resource-list" };
+var _hoisted_11$2 = { class: "resource-name" };
+var _hoisted_12$2 = { class: "resource-group" };
+var _hoisted_13$2 = {
key: 0,
class: "resource-versions"
};
-var _hoisted_14$1 = {
+var _hoisted_14$2 = {
key: 2,
class: "detail-section"
};
-var _hoisted_15$1 = { class: "permission-list" };
-var _hoisted_16$1 = { class: "permission-name" };
-var _hoisted_17$1 = { class: "permission-group" };
+var _hoisted_15$2 = { class: "permission-list" };
+var _hoisted_16$2 = { class: "permission-name" };
+var _hoisted_17$2 = { class: "permission-group" };
var _hoisted_18$1 = {
key: 0,
class: "permission-selector"
@@ -10221,11 +10362,11 @@ var TemplateBindingModal_default = /* @__PURE__ */ _plugin_vue_export_helper_def
class: "modal",
onClick: _cache[1] || (_cache[1] = withModifiers(() => {}, ["stop"]))
}, [
- createBaseVNode("div", _hoisted_1$2, [createBaseVNode("h3", null, "Bind Template: " + toDisplayString(__props.template.metadata.name), 1), createBaseVNode("button", {
+ createBaseVNode("div", _hoisted_1$3, [createBaseVNode("h3", null, "Bind Template: " + toDisplayString(__props.template.metadata.name), 1), createBaseVNode("button", {
onClick: closeModal,
class: "close-btn"
}, "×")]),
- createBaseVNode("div", _hoisted_2$2, [createBaseVNode("div", _hoisted_3$2, [
+ createBaseVNode("div", _hoisted_2$3, [createBaseVNode("div", _hoisted_3$3, [
_cache[2] || (_cache[2] = createBaseVNode("label", {
for: "bindingName",
class: "form-label"
@@ -10238,27 +10379,27 @@ var TemplateBindingModal_default = /* @__PURE__ */ _plugin_vue_export_helper_def
placeholder: "Enter a unique name for this binding",
onKeyup: withKeys(handleBind, ["enter"])
}, null, 34), [[vModelText, bindingName.value]]),
- isValidBindingName.value ? (openBlock(), createElementBlock("p", _hoisted_4$2, "This name will be used to identify your binding in the CLI.")) : (openBlock(), createElementBlock("p", _hoisted_5$2, "Name must be lowercase letters, numbers, and hyphens only. Must start and end with alphanumeric characters."))
- ]), createBaseVNode("div", _hoisted_6$2, [
+ isValidBindingName.value ? (openBlock(), createElementBlock("p", _hoisted_4$3, "This name will be used to identify your binding in the CLI.")) : (openBlock(), createElementBlock("p", _hoisted_5$3, "Name must be lowercase letters, numbers, and hyphens only. Must start and end with alphanumeric characters."))
+ ]), createBaseVNode("div", _hoisted_6$3, [
_cache[10] || (_cache[10] = createBaseVNode("h4", null, "Template Details", -1)),
- __props.template.spec.description ? (openBlock(), createElementBlock("div", _hoisted_7$2, [_cache[3] || (_cache[3] = createBaseVNode("h5", null, "Description", -1)), createBaseVNode("p", _hoisted_8$1, toDisplayString(__props.template.spec.description), 1)])) : createCommentVNode("", true),
- __props.template.spec.resources && __props.template.spec.resources.length > 0 ? (openBlock(), createElementBlock("div", _hoisted_9$1, [createBaseVNode("h5", null, "Resources (" + toDisplayString(__props.template.spec.resources.length) + ")", 1), createBaseVNode("div", _hoisted_10$1, [(openBlock(true), createElementBlock(Fragment, null, renderList(__props.template.spec.resources, (resource) => {
+ __props.template.spec.description ? (openBlock(), createElementBlock("div", _hoisted_7$3, [_cache[3] || (_cache[3] = createBaseVNode("h5", null, "Description", -1)), createBaseVNode("p", _hoisted_8$2, toDisplayString(__props.template.spec.description), 1)])) : createCommentVNode("", true),
+ __props.template.spec.resources && __props.template.spec.resources.length > 0 ? (openBlock(), createElementBlock("div", _hoisted_9$2, [createBaseVNode("h5", null, "Resources (" + toDisplayString(__props.template.spec.resources.length) + ")", 1), createBaseVNode("div", _hoisted_10$2, [(openBlock(true), createElementBlock(Fragment, null, renderList(__props.template.spec.resources, (resource) => {
return openBlock(), createElementBlock("div", {
key: `${resource.group}/${resource.resource}`,
class: "resource-item"
}, [
- createBaseVNode("span", _hoisted_11$1, toDisplayString(resource.resource), 1),
- createBaseVNode("span", _hoisted_12$1, toDisplayString(resource.group || "core"), 1),
- resource.versions ? (openBlock(), createElementBlock("span", _hoisted_13$1, toDisplayString(resource.versions.join(", v")), 1)) : createCommentVNode("", true)
+ createBaseVNode("span", _hoisted_11$2, toDisplayString(resource.resource), 1),
+ createBaseVNode("span", _hoisted_12$2, toDisplayString(resource.group || "core"), 1),
+ resource.versions ? (openBlock(), createElementBlock("span", _hoisted_13$2, toDisplayString(resource.versions.join(", v")), 1)) : createCommentVNode("", true)
]);
}), 128))])])) : createCommentVNode("", true),
- __props.template.spec.permissionClaims && __props.template.spec.permissionClaims.length > 0 ? (openBlock(), createElementBlock("div", _hoisted_14$1, [createBaseVNode("h5", null, "Permission Claims (" + toDisplayString(__props.template.spec.permissionClaims.length) + ")", 1), createBaseVNode("div", _hoisted_15$1, [(openBlock(true), createElementBlock(Fragment, null, renderList(__props.template.spec.permissionClaims, (claim) => {
+ __props.template.spec.permissionClaims && __props.template.spec.permissionClaims.length > 0 ? (openBlock(), createElementBlock("div", _hoisted_14$2, [createBaseVNode("h5", null, "Permission Claims (" + toDisplayString(__props.template.spec.permissionClaims.length) + ")", 1), createBaseVNode("div", _hoisted_15$2, [(openBlock(true), createElementBlock(Fragment, null, renderList(__props.template.spec.permissionClaims, (claim) => {
return openBlock(), createElementBlock("div", {
key: `${claim.group}/${claim.resource}`,
class: "permission-item"
}, [
- createBaseVNode("span", _hoisted_16$1, toDisplayString(claim.resource), 1),
- createBaseVNode("span", _hoisted_17$1, toDisplayString(claim.group || "core"), 1),
+ createBaseVNode("span", _hoisted_16$2, toDisplayString(claim.resource), 1),
+ createBaseVNode("span", _hoisted_17$2, toDisplayString(claim.group || "core"), 1),
claim.selector ? (openBlock(), createElementBlock("div", _hoisted_18$1, [
claim.selector.labelSelector ? (openBlock(), createElementBlock("div", _hoisted_19$1, [_cache[5] || (_cache[5] = createBaseVNode("strong", { class: "selector-title" }, "Label Selector:", -1)), createBaseVNode("div", _hoisted_20$1, [(openBlock(true), createElementBlock(Fragment, null, renderList(getLabelSelectorLabels(claim.selector.labelSelector), (value, key) => {
return openBlock(), createElementBlock("div", {
@@ -10305,6 +10446,126 @@ var TemplateBindingModal_default = /* @__PURE__ */ _plugin_vue_export_helper_def
}
}), [["__scopeId", "data-v-4218c1a0"]]);
//#endregion
+//#region src/components/TemplateDetails.vue?vue&type=script&setup=true&lang.ts
+var _hoisted_1$2 = { class: "modal-container" };
+var _hoisted_2$2 = { class: "modal-header" };
+var _hoisted_3$2 = { class: "modal-body" };
+var _hoisted_4$2 = { class: "detail-section" };
+var _hoisted_5$2 = {
+ key: 0,
+ class: "tag-list"
+};
+var _hoisted_6$2 = {
+ key: 1,
+ class: "empty-note"
+};
+var _hoisted_7$2 = {
+ key: 0,
+ class: "detail-section"
+};
+var _hoisted_8$1 = { class: "tag-list" };
+var _hoisted_9$1 = {
+ key: 1,
+ class: "detail-section"
+};
+var _hoisted_10$1 = { class: "detail-section exports-section" };
+var _hoisted_11$1 = {
+ key: 0,
+ class: "status-loading"
+};
+var _hoisted_12$1 = {
+ key: 1,
+ class: "exports-list"
+};
+var _hoisted_13$1 = { class: "connection-info" };
+var _hoisted_14$1 = { class: "ns-label" };
+var _hoisted_15$1 = { class: "tag-list" };
+var _hoisted_16$1 = {
+ key: 2,
+ class: "empty-note"
+};
+var _hoisted_17$1 = { class: "modal-footer" };
+//#endregion
+//#region src/components/TemplateDetails.vue
+var TemplateDetails_default = /* @__PURE__ */ _plugin_vue_export_helper_default(/* @__PURE__ */ defineComponent({
+ __name: "TemplateDetails",
+ props: {
+ show: { type: Boolean },
+ template: {}
+ },
+ emits: ["close", "bind"],
+ setup(__props) {
+ const props = __props;
+ const status = /* @__PURE__ */ ref(null);
+ const statusLoading = /* @__PURE__ */ ref(false);
+ const fetchStatus = function() {
+ var _ref = _asyncToGenerator(function* () {
+ statusLoading.value = true;
+ try {
+ const response = yield fetch("/api/consumer-status");
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ status.value = yield response.json();
+ } catch (err) {
+ console.error("Failed to check consumer status:", err);
+ status.value = null;
+ } finally {
+ statusLoading.value = false;
+ }
+ });
+ return function fetchStatus() {
+ return _ref.apply(this, arguments);
+ };
+ }();
+ watch(() => props.show, (visible) => {
+ if (visible) {
+ status.value = null;
+ fetchStatus();
+ }
+ });
+ return (_ctx, _cache) => {
+ var _props$template$spec$, _props$template$spec$2, _status$value;
+ return __props.show ? (openBlock(), createElementBlock("div", {
+ key: 0,
+ class: "modal-overlay",
+ onClick: _cache[3] || (_cache[3] = withModifiers(($event) => _ctx.$emit("close"), ["self"]))
+ }, [createBaseVNode("div", _hoisted_1$2, [
+ createBaseVNode("div", _hoisted_2$2, [createBaseVNode("h3", null, toDisplayString(__props.template.metadata.name), 1), createBaseVNode("button", {
+ class: "close-btn",
+ onClick: _cache[0] || (_cache[0] = ($event) => _ctx.$emit("close"))
+ }, "×")]),
+ createBaseVNode("div", _hoisted_3$2, [
+ createBaseVNode("section", _hoisted_4$2, [_cache[4] || (_cache[4] = createBaseVNode("h4", null, "Resources", -1)), ((_props$template$spec$ = __props.template.spec.resources) === null || _props$template$spec$ === void 0 ? void 0 : _props$template$spec$.length) ? (openBlock(), createElementBlock("div", _hoisted_5$2, [(openBlock(true), createElementBlock(Fragment, null, renderList(__props.template.spec.resources, (r) => {
+ return openBlock(), createElementBlock("span", {
+ key: r.resource,
+ class: "tag"
+ }, toDisplayString(r.group ? `${r.resource}.${r.group}` : r.resource), 1);
+ }), 128))])) : (openBlock(), createElementBlock("p", _hoisted_6$2, "No resources defined."))]),
+ ((_props$template$spec$2 = __props.template.spec.permissionClaims) === null || _props$template$spec$2 === void 0 ? void 0 : _props$template$spec$2.length) ? (openBlock(), createElementBlock("section", _hoisted_7$2, [_cache[5] || (_cache[5] = createBaseVNode("h4", null, "Permission Claims", -1)), createBaseVNode("div", _hoisted_8$1, [(openBlock(true), createElementBlock(Fragment, null, renderList(__props.template.spec.permissionClaims, (claim, i) => {
+ return openBlock(), createElementBlock("span", {
+ key: i,
+ class: "tag permission-tag"
+ }, toDisplayString(claim.group ? `${claim.resource}.${claim.group}` : claim.resource), 1);
+ }), 128))])])) : createCommentVNode("", true),
+ __props.template.spec.description ? (openBlock(), createElementBlock("section", _hoisted_9$1, [_cache[6] || (_cache[6] = createBaseVNode("h4", null, "Description", -1)), createBaseVNode("p", null, toDisplayString(__props.template.spec.description), 1)])) : createCommentVNode("", true),
+ createBaseVNode("section", _hoisted_10$1, [_cache[10] || (_cache[10] = createBaseVNode("h4", null, "Active Service Exports", -1)), statusLoading.value ? (openBlock(), createElementBlock("div", _hoisted_11$1, [..._cache[7] || (_cache[7] = [createBaseVNode("span", { class: "spinner" }, null, -1), createTextVNode(" Checking connection… ", -1)])])) : ((_status$value = status.value) === null || _status$value === void 0 ? void 0 : _status$value.connected) && status.value.exports.length > 0 ? (openBlock(), createElementBlock("div", _hoisted_12$1, [createBaseVNode("div", _hoisted_13$1, [_cache[9] || (_cache[9] = createBaseVNode("span", { class: "connected-badge" }, "Connected", -1)), createBaseVNode("span", _hoisted_14$1, [_cache[8] || (_cache[8] = createTextVNode("Namespace: ", -1)), createBaseVNode("strong", null, toDisplayString(status.value.namespace), 1)])]), createBaseVNode("div", _hoisted_15$1, [(openBlock(true), createElementBlock(Fragment, null, renderList(status.value.exports, (name) => {
+ return openBlock(), createElementBlock("span", {
+ key: name,
+ class: "tag export-tag"
+ }, toDisplayString(name), 1);
+ }), 128))])])) : (openBlock(), createElementBlock("div", _hoisted_16$1, " No active exports. Bind this template to create service exports on the provider. "))])
+ ]),
+ createBaseVNode("div", _hoisted_17$1, [createBaseVNode("button", {
+ class: "secondary-btn",
+ onClick: _cache[1] || (_cache[1] = ($event) => _ctx.$emit("close"))
+ }, "Close"), createBaseVNode("button", {
+ class: "primary-btn",
+ onClick: _cache[2] || (_cache[2] = ($event) => _ctx.$emit("bind", __props.template))
+ }, "Bind")])
+ ])])) : createCommentVNode("", true);
+ };
+ }
+}), [["__scopeId", "data-v-4c7f1934"]]);
+//#endregion
//#region src/components/AlertModal.vue?vue&type=script&setup=true&lang.ts
var _hoisted_1$1 = { class: "modal-content" };
var _hoisted_2$1 = { class: "message-content" };
@@ -10500,6 +10761,8 @@ var Resources_default = /* @__PURE__ */ _plugin_vue_export_helper_default(/* @__
const alertType = /* @__PURE__ */ ref("error");
const alertPreserveWhitespace = /* @__PURE__ */ ref(false);
const isCliFlow = computed(() => authService.isCliFlow());
+ const showDetails = /* @__PURE__ */ ref(false);
+ const detailsTemplate = /* @__PURE__ */ ref(null);
const showBindingModal = /* @__PURE__ */ ref(false);
const selectedTemplate = /* @__PURE__ */ ref(null);
const cluster = computed(() => route.query.cluster_id || "");
@@ -10559,6 +10822,15 @@ var Resources_default = /* @__PURE__ */ _plugin_vue_export_helper_default(/* @__
selectedTemplate.value = null;
};
const showTemplateDetails = (template) => {
+ detailsTemplate.value = template;
+ showDetails.value = true;
+ };
+ const closeDetails = () => {
+ showDetails.value = false;
+ detailsTemplate.value = null;
+ };
+ const onDetailsBind = (template) => {
+ closeDetails();
openBindingModal(template);
};
const handleBind = function() {
@@ -10696,8 +10968,15 @@ var Resources_default = /* @__PURE__ */ _plugin_vue_export_helper_default(/* @__
}, toDisplayString(templateName), 1);
}), 128)), collection.spec.templates.length > 4 ? (openBlock(), createElementBlock("span", _hoisted_36, " +" + toDisplayString(collection.spec.templates.length - 4) + " more ", 1)) : createCommentVNode("", true)])])) : createCommentVNode("", true)])]);
}), 128))])])) : createCommentVNode("", true)])),
- selectedTemplate.value ? (openBlock(), createBlock(TemplateBindingModal_default, {
+ detailsTemplate.value ? (openBlock(), createBlock(TemplateDetails_default, {
key: 3,
+ show: showDetails.value,
+ template: detailsTemplate.value,
+ onClose: closeDetails,
+ onBind: onDetailsBind
+ }, null, 8, ["show", "template"])) : createCommentVNode("", true),
+ selectedTemplate.value ? (openBlock(), createBlock(TemplateBindingModal_default, {
+ key: 4,
show: showBindingModal.value,
template: selectedTemplate.value,
"is-cli-flow": isCliFlow.value,
@@ -10709,7 +10988,7 @@ var Resources_default = /* @__PURE__ */ _plugin_vue_export_helper_default(/* @__
"is-cli-flow"
])) : createCommentVNode("", true),
bindingResponse.value ? (openBlock(), createBlock(BindingResult_default, {
- key: 4,
+ key: 5,
show: showBindingResult.value,
"template-name": selectedTemplateName.value,
"binding-response": bindingResponse.value,
@@ -10736,7 +11015,7 @@ var Resources_default = /* @__PURE__ */ _plugin_vue_export_helper_default(/* @__
]);
};
}
-}), [["__scopeId", "data-v-2bef16ba"]]);
+}), [["__scopeId", "data-v-1cd8a497"]]);
//#endregion
//#region src/main.ts
var routes = [{
diff --git a/web/dist/index.html b/web/dist/index.html
index 14028821f..41d246b70 100644
--- a/web/dist/index.html
+++ b/web/dist/index.html
@@ -5,8 +5,8 @@
Kube Bind
-
-
+
+
diff --git a/web/src/components/BindingResult.vue b/web/src/components/BindingResult.vue
index c93b12493..c325c69d4 100644
--- a/web/src/components/BindingResult.vue
+++ b/web/src/components/BindingResult.vue
@@ -2,52 +2,160 @@
-
+
-
-
Binding Information
-
Template: {{ templateName }}
-
Binding Name: {{ bindingName }}
-
Kubeconfig Secret: {{ kubeconfigSecretName }}
+
+
+
+ Provide Kubeconfig
+
+
+ APIServiceBindingBundle
+
+
+ Manual (CLI)
+
+
+
+
+
+
+ Upload your consumer cluster kubeconfig and we'll automatically deploy the konnector agent
+ and configure the binding bundle for you. No CLI required.
+
+
+
+ ⚠️ Security Note: Your kubeconfig will be used transiently to apply resources
+ to your consumer cluster and will not be stored. The provider backend needs
+ cluster-admin level access to deploy the konnector and create binding resources.
+
+
+
+
Upload Consumer Kubeconfig
+
Select the kubeconfig file for your consumer cluster.
+
+
+
+ 📄 {{ kubeconfigFileName }}
+
+
+
+
+
+
+
+ {{ applyStatus === 'loading' ? 'Applying...' : 'Apply to Consumer Cluster' }}
+
+
+
+
+ ✅ {{ applyMessage }}
+
+
+ ❌ {{ applyMessage }}
+
+
+
+
+
+
+ Download and apply these files to your consumer cluster.
+ The APIServiceBindingBundle will automatically discover and bind services from the provider.
+
+
+
+
1. Deploy the konnector agent (one-time)
+
Skip this step if the konnector is already deployed on your consumer cluster.
+
+
+ Download konnector.yaml
+
+
+
+ kubectl apply -f konnector.yaml
+ Copy
+
+
+
+
+
2. Apply the binding bundle
+
Creates the kubeconfig secret and an APIServiceBindingBundle that auto-discovers and binds all services.
+
+
+ Download binding-bundle.yaml
+
+
+
+ kubectl apply -f binding-bundle.yaml
+ Copy
+
+
+
+
+
3. Verify
+
+ kubectl get apiservicebindingbundles,apiservicebindings
+ Copy
+
+
-
-
-
Setup Instructions
+
+
+
- To complete the binding setup, first download the required files below, then execute the following commands in your local kubectl environment:
+ Download the required files and run the following commands on your consumer cluster.
-
-
+
+
1. Download required files
-
Download and save both files in your current directory:
- Download kubeconfig.yaml
- Download apiservice-export.yaml
+ Download kubeconfig.yaml
+ Download apiservice-export.yaml
-
-
-
2. Create kube-bind namespace (if it doesn't exist)
+
+
+
2. Create kube-bind namespace
kubectl create namespace kube-bind --dry-run=client -o yaml | kubectl apply -f -
Copy
-
-
+
+
3. Create kubeconfig secret
{{ createSecretCommand }}
Copy
-
-
+
+
4. Bind the API service
{{ bindCommand }}
@@ -55,23 +163,8 @@
-
-
-
- Alternative: Use stdin piping
-
-
For advanced users who prefer piping:
-
-
- cat apiservice-export.yaml | kubectl bind apiservice --remote-kubeconfig-namespace kube-bind --remote-kubeconfig-name {{ kubeconfigSecretName }} -f -
- Copy
-
-
-
-
-
-
+
@@ -83,6 +176,12 @@
import { ref, computed } from 'vue'
import type { BindingResponse } from '../types/binding'
+interface ApplyResult {
+ konnectorDeployed: boolean
+ bundleCreated: boolean
+ message: string
+}
+
interface Props {
show: boolean
templateName: string
@@ -94,17 +193,25 @@ const emit = defineEmits<{
close: []
}>()
-// Generate a secret name based on template and timestamp for consistency
-const kubeconfigSecretName = computed(() => {
- return `kubeconfig-${props.templateName.toLowerCase().replace(/[^a-z0-9]/g, '')}-${Date.now().toString(36)}`
-})
+const activeMethod = ref<'oneclick' | 'bundle' | 'manual'>('oneclick')
+
+// One-Click state
+const kubeconfigData = ref
(null)
+const kubeconfigFileName = ref('')
+const applyStatus = ref<'idle' | 'loading' | 'success' | 'error'>('idle')
+const applyMessage = ref('')
+const kubeconfigFileInput = ref(null)
const bindingName = computed(() => {
- // Extract binding name from authentication or use template name
- return props.bindingResponse.authentication?.oauth2CodeGrant?.sessionID || props.templateName
+ return props.bindingResponse.bindingName || props.templateName
})
-// Generate the kubectl commands
+const kubeconfigSecretName = computed(() => {
+ const safeName = bindingName.value.toLowerCase().replace(/[^a-z0-9-]/g, '-')
+ return `kubeconfig-${safeName}`
+})
+
+// Manual method commands
const createSecretCommand = computed(() => {
return `kubectl create secret generic ${kubeconfigSecretName.value} --from-file=kubeconfig=./kubeconfig.yaml -n kube-bind`
})
@@ -113,6 +220,59 @@ const bindCommand = computed(() => {
return `kubectl bind apiservice --remote-kubeconfig-namespace kube-bind --remote-kubeconfig-name ${kubeconfigSecretName.value} -f apiservice-export.yaml`
})
+// One-Click: handle kubeconfig file upload
+const handleKubeconfigUpload = (event: Event) => {
+ const target = event.target as HTMLInputElement
+ const file = target.files?.[0]
+ if (!file) return
+
+ kubeconfigFileName.value = file.name
+ applyStatus.value = 'idle'
+ applyMessage.value = ''
+
+ const reader = new FileReader()
+ reader.onload = (e) => {
+ const content = e.target?.result as string
+ // Base64 encode the kubeconfig content
+ kubeconfigData.value = btoa(content)
+ }
+ reader.readAsText(file)
+}
+
+// One-Click: apply binding to consumer cluster
+const applyToConsumer = async () => {
+ if (!kubeconfigData.value) return
+
+ applyStatus.value = 'loading'
+ applyMessage.value = ''
+
+ try {
+ const response = await fetch('/api/apply-binding', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ consumerKubeconfig: kubeconfigData.value,
+ bindingName: bindingName.value,
+ }),
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => null)
+ throw new Error(errorData?.message || `HTTP ${response.status}`)
+ }
+
+ const result: ApplyResult = await response.json()
+ applyStatus.value = 'success'
+ applyMessage.value = result.message
+ if (result.konnectorDeployed) {
+ applyMessage.value += ' (konnector was newly deployed)'
+ }
+ } catch (error: any) {
+ applyStatus.value = 'error'
+ applyMessage.value = error.message || 'Failed to apply binding'
+ }
+}
+
const closeModal = () => {
emit('close')
}
@@ -122,7 +282,6 @@ const copyCommand = async (command: string) => {
await navigator.clipboard.writeText(command)
} catch (err) {
console.error('Failed to copy command:', err)
- // Fallback for older browsers
const textarea = document.createElement('textarea')
textarea.value = command
document.body.appendChild(textarea)
@@ -132,141 +291,97 @@ const copyCommand = async (command: string) => {
}
}
-const downloadKubeconfig = () => {
+const triggerDownload = (content: string, filename: string, type = 'text/yaml') => {
+ const blob = new Blob([content], { type })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+}
+
+// Decode kubeconfig from base64
+const decodedKubeconfig = computed(() => {
try {
- // Decode base64 kubeconfig
- const decodedKubeconfig = atob(props.bindingResponse.kubeconfig)
- const blob = new Blob([decodedKubeconfig], { type: 'text/yaml' })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = 'kubeconfig.yaml'
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
+ return atob(props.bindingResponse.kubeconfig)
+ } catch {
+ return props.bindingResponse.kubeconfig
+ }
+})
+
+// Bundle method: binding-bundle.yaml
+const bindingBundleYaml = computed(() => {
+ const kubeconfigBase64 = props.bindingResponse.kubeconfig
+ const secretName = kubeconfigSecretName.value
+ const bundleName = bindingName.value
+
+ return `apiVersion: v1
+kind: Namespace
+metadata:
+ name: kube-bind
+---
+apiVersion: v1
+kind: Secret
+metadata:
+ name: ${secretName}
+ namespace: kube-bind
+type: Opaque
+data:
+ kubeconfig: ${kubeconfigBase64}
+---
+apiVersion: kube-bind.io/v1alpha2
+kind: APIServiceBindingBundle
+metadata:
+ name: ${bundleName}
+spec:
+ kubeconfigSecretRef:
+ name: ${secretName}
+ namespace: kube-bind
+ key: kubeconfig
+`
+})
+
+const downloadBindingBundle = () => {
+ triggerDownload(bindingBundleYaml.value, 'binding-bundle.yaml')
+}
+
+const downloadKonnectorManifests = async () => {
+ try {
+ const response = await fetch('/api/konnector-manifests')
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`)
+ }
+ const yaml = await response.text()
+ triggerDownload(yaml, 'konnector.yaml')
} catch (error) {
- console.error('Failed to decode kubeconfig:', error)
- // Fallback: try downloading without decoding in case it's not base64
- const blob = new Blob([props.bindingResponse.kubeconfig], { type: 'text/yaml' })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = 'kubeconfig.yaml'
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
+ console.error('Failed to fetch konnector manifests:', error)
}
}
+// Manual method: individual file downloads
+const downloadKubeconfig = () => {
+ triggerDownload(decodedKubeconfig.value, 'kubeconfig.yaml')
+}
+
const downloadAPIRequests = () => {
try {
- // Convert API requests to proper YAML format
const apiRequestsYaml = props.bindingResponse.requests.map(req => {
if (typeof req === 'string') {
- // If it's already a string, assume it's YAML
return req.trim()
} else {
- // Convert object to YAML-like format
- // Note: For proper YAML, you'd want to use a YAML library like js-yaml
- // For now, we'll format it as readable YAML-like structure
- return formatObjectAsYaml(req)
+ return JSON.stringify(req, null, 2)
}
}).join('\n---\n')
-
- const blob = new Blob([apiRequestsYaml], { type: 'text/yaml' })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = 'apiservice-export.yaml'
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
- } catch (error) {
- console.error('Failed to format API requests as YAML:', error)
- // Fallback: download as JSON
- const apiRequestsJson = JSON.stringify(props.bindingResponse.requests, null, 2)
- const blob = new Blob([apiRequestsJson], { type: 'application/json' })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = 'apiservice-export.json'
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(url)
- }
-}
-// Helper function to format objects as proper YAML structure
-const formatObjectAsYaml = (obj: any, indent: number = 0): string => {
- const spaces = ' '.repeat(indent)
-
- if (obj === null || obj === undefined) {
- return ''
- }
-
- if (typeof obj === 'string') {
- // Handle multiline strings
- if (obj.includes('\n')) {
- return `|\n${spaces} ${obj.replace(/\n/g, `\n${spaces} `)}`
- }
- // Quote strings that might be ambiguous in YAML
- if (obj.match(/^(true|false|null|\d+)$/i) || obj.includes(':') || obj.includes('#')) {
- return `"${obj}"`
- }
- return obj
- }
-
- if (typeof obj === 'number' || typeof obj === 'boolean') {
- return String(obj)
- }
-
- if (Array.isArray(obj)) {
- if (obj.length === 0) return '[]'
- return obj.map(item => {
- if (typeof item === 'object' && item !== null && !Array.isArray(item)) {
- // For object items in arrays, format them with proper indentation
- const formattedItem = formatObjectAsYaml(item, indent + 2)
- return `${spaces}- ${formattedItem.replace(/^\s*/, '')}`
- } else {
- // For simple items (strings, numbers, booleans)
- return `${spaces}- ${item}`
- }
- }).join('\n')
- }
-
- if (typeof obj === 'object') {
- return Object.entries(obj).map(([key, value]) => {
- if (value === null || value === undefined) {
- return `${spaces}${key}:`
- }
-
- if (Array.isArray(value)) {
- if (value.length === 0) {
- return `${spaces}${key}: []`
- }
- const formattedArray = formatObjectAsYaml(value, indent + 2)
- return `${spaces}${key}:\n${formattedArray}`
- }
-
- if (typeof value === 'object') {
- const formattedObject = formatObjectAsYaml(value, indent + 2)
- if (formattedObject.trim() === '') {
- return `${spaces}${key}:`
- }
- return `${spaces}${key}:\n${formattedObject}`
- }
-
- // Simple values
- const formattedValue = formatObjectAsYaml(value, 0)
- return `${spaces}${key}: ${formattedValue}`
- }).join('\n')
+ triggerDownload(apiRequestsYaml, 'apiservice-export.yaml')
+ } catch (error) {
+ console.error('Failed to format API requests:', error)
+ const json = JSON.stringify(props.bindingResponse.requests, null, 2)
+ triggerDownload(json, 'apiservice-export.json', 'application/json')
}
-
- return String(obj)
}
@@ -301,7 +416,7 @@ const formatObjectAsYaml = (obj: any, indent: number = 0): string => {
align-items: center;
padding: 1.5rem 2rem;
border-bottom: 1px solid #e5e7eb;
- background: linear-gradient(135deg, #10b981 0%, #059669 100%);
+ background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: white;
}
@@ -338,7 +453,7 @@ const formatObjectAsYaml = (obj: any, indent: number = 0): string => {
}
.binding-info {
- margin-bottom: 2rem;
+ margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
@@ -356,15 +471,39 @@ const formatObjectAsYaml = (obj: any, indent: number = 0): string => {
font-size: 0.9rem;
}
-.instructions-section {
- margin-bottom: 2rem;
+.method-tabs {
+ display: flex;
+ gap: 0;
+ margin-bottom: 1.5rem;
+ border-bottom: 2px solid #e5e7eb;
+ flex-wrap: wrap;
+}
+
+.method-tab {
+ padding: 0.75rem 1.25rem;
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -2px;
+ cursor: pointer;
+ font-size: 0.85rem;
+ font-weight: 500;
+ color: #6b7280;
+ transition: all 0.2s;
+ white-space: nowrap;
}
-.instructions-section h4 {
+.method-tab:hover {
+ color: #374151;
+}
+
+.method-tab.active {
+ color: #3b82f6;
+ border-bottom-color: #3b82f6;
+}
+
+.instructions-section {
margin-bottom: 1rem;
- color: #111827;
- font-size: 1.125rem;
- font-weight: 600;
}
.instructions-text {
@@ -373,51 +512,58 @@ const formatObjectAsYaml = (obj: any, indent: number = 0): string => {
line-height: 1.6;
}
-.command-group {
- margin-bottom: 1.5rem;
-}
-
-.download-files-section {
- margin-bottom: 1.5rem;
+.step-group {
+ margin-bottom: 2rem;
}
-.download-files-section h5 {
- margin-bottom: 0.75rem;
+.step-group h5 {
+ margin-bottom: 0.5rem;
color: #374151;
font-size: 1rem;
font-weight: 600;
}
+.step-description {
+ margin-bottom: 0.75rem;
+ color: #6b7280;
+ font-size: 0.875rem;
+}
+
.download-block {
background: #f0f9ff;
border: 1px solid #bfdbfe;
border-radius: 8px;
padding: 1rem;
+ margin-bottom: 0.75rem;
}
-.download-text {
- margin-bottom: 1rem;
- color: #374151;
+.download-actions {
+ display: flex;
+ gap: 1rem;
+}
+
+.download-btn {
+ padding: 0.75rem 1.5rem;
+ background: #6b7280;
+ color: white;
+ border: none;
+ border-radius: 8px;
+ cursor: pointer;
font-size: 0.875rem;
+ font-weight: 500;
+ transition: background-color 0.2s;
}
-.download-text code {
- font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', monospace;
- background: #e5e7eb;
- padding: 0.125rem 0.375rem;
- border-radius: 4px;
- font-size: 0.8125rem;
+.download-btn:hover {
+ background: #4b5563;
}
-.command-group {
- margin-bottom: 1.5rem;
+.download-btn.primary {
+ background: #3b82f6;
}
-.command-group h5 {
- margin-bottom: 0.75rem;
- color: #374151;
- font-size: 1rem;
- font-weight: 600;
+.download-btn.primary:hover {
+ background: #2563eb;
}
.command-block {
@@ -456,52 +602,162 @@ const formatObjectAsYaml = (obj: any, indent: number = 0): string => {
background: #2563eb;
}
-.alternative-section {
- margin-top: 2rem;
- padding-top: 1.5rem;
- border-top: 1px solid #e5e7eb;
+/* One-Click tab styles */
+.security-warning {
+ background: #fffbeb;
+ border: 1px solid #fcd34d;
+ border-radius: 8px;
+ padding: 1rem;
+ margin-bottom: 1.5rem;
+ color: #92400e;
+ font-size: 0.875rem;
+ line-height: 1.5;
}
-.alternative-section details {
- cursor: pointer;
+.upload-block {
+ background: #f9fafb;
+ border: 2px dashed #d1d5db;
+ border-radius: 8px;
+ padding: 1.5rem;
+ text-align: center;
}
-.alternative-section summary {
+.file-input {
+ margin-bottom: 0.5rem;
+}
+
+.file-info {
+ margin-top: 0.5rem;
+ color: #059669;
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.apply-btn {
+ padding: 0.875rem 2rem;
+ background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
+ color: white;
+ border: none;
+ border-radius: 8px;
+ cursor: pointer;
+ font-size: 1rem;
font-weight: 600;
+ transition: all 0.2s;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.apply-btn:hover:not(:disabled) {
+ background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
+ transform: translateY(-1px);
+}
+
+.apply-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.spinner {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba(255, 255, 255, 0.3);
+ border-radius: 50%;
+ border-top-color: white;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+.status-message {
+ padding: 1rem;
+ border-radius: 8px;
+ font-size: 0.9rem;
+ margin-top: 1rem;
+}
+
+.status-message.success {
+ background: #ecfdf5;
+ border: 1px solid #a7f3d0;
+ color: #065f46;
+}
+
+.status-message.error {
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #991b1b;
+}
+
+/* Already Connected tab styles */
+.status-check {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 2rem;
+ justify-content: center;
color: #6b7280;
- padding: 0.5rem 0;
- outline: none;
}
-.manual-setup {
- padding: 1rem 0;
+.status-check .spinner {
+ border-color: rgba(59, 130, 246, 0.3);
+ border-top-color: #3b82f6;
}
-.manual-setup h5 {
- margin-bottom: 1rem;
- color: #374151;
+.connected-badge {
+ background: #ecfdf5;
+ border: 1px solid #a7f3d0;
+ color: #065f46;
+ padding: 0.75rem 1rem;
+ border-radius: 8px;
font-weight: 600;
+ font-size: 1rem;
+ margin-bottom: 1rem;
}
-.download-actions {
- display: flex;
- gap: 1rem;
+.not-connected-badge {
+ background: #f0f9ff;
+ border: 1px solid #bfdbfe;
+ color: #1e40af;
+ padding: 0.75rem 1rem;
+ border-radius: 8px;
+ font-weight: 600;
+ font-size: 1rem;
+ margin-bottom: 1rem;
}
-.download-btn {
- padding: 0.75rem 1.5rem;
- background: #6b7280;
- color: white;
- border: none;
+.exports-list {
+ background: #f9fafb;
+ border: 1px solid #e5e7eb;
border-radius: 8px;
- cursor: pointer;
+ padding: 1rem;
+ margin-bottom: 1.5rem;
+}
+
+.exports-list h5 {
+ margin: 0 0 0.5rem 0;
+ color: #374151;
+ font-size: 0.9rem;
+}
+
+.exports-list ul {
+ margin: 0;
+ padding-left: 1.5rem;
+}
+
+.exports-list li {
+ color: #6b7280;
font-size: 0.875rem;
- font-weight: 500;
- transition: background-color 0.2s;
+ font-family: 'SF Mono', 'Monaco', monospace;
+ padding: 0.25rem 0;
}
-.download-btn:hover {
- background: #4b5563;
+.method-suggestions {
+ padding-left: 1.5rem;
+ color: #6b7280;
+ line-height: 2;
}
.binding-footer {
diff --git a/web/src/components/TemplateDetails.vue b/web/src/components/TemplateDetails.vue
new file mode 100644
index 000000000..f68c248ff
--- /dev/null
+++ b/web/src/components/TemplateDetails.vue
@@ -0,0 +1,315 @@
+
+
+
+
+
+
+
+
+ Resources
+
+
+ {{ r.group ? `${r.resource}.${r.group}` : r.resource }}
+
+
+ No resources defined.
+
+
+
+ Permission Claims
+
+
+ {{ claim.group ? `${claim.resource}.${claim.group}` : claim.resource }}
+
+
+
+
+
+ Description
+ {{ template.spec.description }}
+
+
+
+
+ Active Service Exports
+
+ Checking connection…
+
+
+
+ Connected
+ Namespace: {{ status.namespace }}
+
+
+
+ {{ name }}
+
+
+
+
+ No active exports. Bind this template to create service exports on the provider.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/types/binding.ts b/web/src/types/binding.ts
index 77ffc05ee..e51bd251a 100644
--- a/web/src/types/binding.ts
+++ b/web/src/types/binding.ts
@@ -44,6 +44,8 @@ export interface BindingResponse {
authentication: BindingResponseAuthentication
kubeconfig: string
requests: any[]
+ providerNamespace?: string
+ bindingName?: string
}
export interface BindingResult {
diff --git a/web/src/views/Resources.vue b/web/src/views/Resources.vue
index 5e9988127..d58aca2a7 100644
--- a/web/src/views/Resources.vue
+++ b/web/src/views/Resources.vue
@@ -123,6 +123,15 @@
+
+
+
('error')
const alertPreserveWhitespace = ref(false)
const isCliFlow = computed(() => authService.isCliFlow())
+// Consumer connection status (shown in TemplateDetails modal)
+const showDetails = ref(false)
+const detailsTemplate = ref(null)
+
const showBindingModal = ref(false)
const selectedTemplate = ref(null)
@@ -312,7 +326,17 @@ const closeBindingModal = () => {
}
const showTemplateDetails = (template: Template) => {
- // For now, just open the binding modal - could be extended to show details-only modal
+ detailsTemplate.value = template
+ showDetails.value = true
+}
+
+const closeDetails = () => {
+ showDetails.value = false
+ detailsTemplate.value = null
+}
+
+const onDetailsBind = (template: Template) => {
+ closeDetails()
openBindingModal(template)
}