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("

Kube Bind

", 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("

Kube Bind

", 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 @@
-

Template Binding Successful

+

Setup Binding: {{ templateName }}

- +
-
-

Binding Information

-

Template: {{ templateName }}

-

Binding Name: {{ bindingName }}

-

Kubeconfig Secret: {{ kubeconfigSecretName }}

+ +
+ + + +
+ + +
+

+ 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 }} +
+
+
+ +
+ +
+ +
+ ✅ {{ 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.

+
+
+ +
+
+
+ kubectl apply -f konnector.yaml + +
+
+ +
+
2. Apply the binding bundle
+

Creates the kubeconfig secret and an APIServiceBindingBundle that auto-discovers and binds all services.

+
+
+ +
+
+
+ kubectl apply -f binding-bundle.yaml + +
+
+ +
+
3. Verify
+
+ kubectl get apiservicebindingbundles,apiservicebindings + +
+
- -
-

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:

- - + +
- -
-
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 -
- -
+ +
3. Create kubeconfig secret
{{ createSecretCommand }}
- -
+ +
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 - - -
-
-
-
-
- + @@ -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 @@ + + + + + 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