diff --git a/backend/controllers/serviceexportrequest/serviceexportrequest_controller.go b/backend/controllers/serviceexportrequest/serviceexportrequest_controller.go index 212aa49f8..318584c0f 100644 --- a/backend/controllers/serviceexportrequest/serviceexportrequest_controller.go +++ b/backend/controllers/serviceexportrequest/serviceexportrequest_controller.go @@ -105,6 +105,9 @@ func NewAPIServiceExportRequestReconciler( createBoundSchema: func(ctx context.Context, cl client.Client, schema *kubebindv1alpha2.BoundSchema) error { return cl.Create(ctx, schema) }, + updateBoundSchema: func(ctx context.Context, cl client.Client, schema *kubebindv1alpha2.BoundSchema) error { + return cl.Update(ctx, schema) + }, deleteServiceExportRequest: func(ctx context.Context, cl client.Client, ns, name string) error { return cl.Delete(ctx, &kubebindv1alpha2.APIServiceExportRequest{ ObjectMeta: metav1.ObjectMeta{ diff --git a/backend/controllers/serviceexportrequest/serviceexportrequest_reconcile.go b/backend/controllers/serviceexportrequest/serviceexportrequest_reconcile.go index 617c09930..9cd71cb7e 100644 --- a/backend/controllers/serviceexportrequest/serviceexportrequest_reconcile.go +++ b/backend/controllers/serviceexportrequest/serviceexportrequest_reconcile.go @@ -31,6 +31,7 @@ import ( "k8s.io/klog/v2" "sigs.k8s.io/controller-runtime/pkg/cache" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "github.com/kube-bind/kube-bind/backend/kubernetes/resources" kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" @@ -46,6 +47,7 @@ type reconciler struct { getBoundSchema func(ctx context.Context, cl client.Client, namespace, name string) (*kubebindv1alpha2.BoundSchema, error) createBoundSchema func(ctx context.Context, cl client.Client, schema *kubebindv1alpha2.BoundSchema) error + updateBoundSchema func(ctx context.Context, cl client.Client, schema *kubebindv1alpha2.BoundSchema) error getServiceExport func(ctx context.Context, cache cache.Cache, ns, name string) (*kubebindv1alpha2.APIServiceExport, error) createServiceExport func(ctx context.Context, cl client.Client, resource *kubebindv1alpha2.APIServiceExport) error @@ -53,9 +55,13 @@ type reconciler struct { } func (r *reconciler) reconcile(ctx context.Context, cl client.Client, cache cache.Cache, req *kubebindv1alpha2.APIServiceExportRequest) error { + export, err := r.getServiceExport(ctx, cache, req.Namespace, req.Name) + if err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("failed to get APIServiceExport: %w", err) + } // We must ensure schemas are created in form of boundSchemas first for the validation. // Worst case scenario if validation fails, we will reuse schemas for same consumer once issues are fixed. - if err := r.ensureBoundSchemas(ctx, cl, cache, req); err != nil { + if err := r.ensureBoundSchemas(ctx, cl, export, req); err != nil { conditions.SetSummary(req) return fmt.Errorf("failed to ensure bound schemas: %w", err) } @@ -65,7 +71,7 @@ func (r *reconciler) reconcile(ctx context.Context, cl client.Client, cache cach return fmt.Errorf("failed to validate APIServiceExportRequest: %w", err) } - if err := r.ensureExports(ctx, cl, cache, req); err != nil { + if err := r.ensureExports(ctx, cl, export, req); err != nil { conditions.SetSummary(req) return fmt.Errorf("failed to ensure exports: %w", err) } @@ -75,10 +81,6 @@ func (r *reconciler) reconcile(ctx context.Context, cl client.Client, cache cach return fmt.Errorf("failed to ensure APIServiceNamespaces: %w", err) } - // TODO(mjudeikis): we could potentially add finallizer to APIServiceExport above or "adopt" boundschemas - // with owner references once export is created. - // https://github.com/kube-bind/kube-bind/issues/297 - conditions.SetSummary(req) return nil @@ -134,13 +136,12 @@ func (r *reconciler) getExportedSchemas(ctx context.Context, cl client.Client) ( return boundSchemas, nil } -func (r *reconciler) ensureBoundSchemas(ctx context.Context, cl client.Client, _ cache.Cache, req *kubebindv1alpha2.APIServiceExportRequest) error { +func (r *reconciler) ensureBoundSchemas(ctx context.Context, cl client.Client, export *kubebindv1alpha2.APIServiceExport, req *kubebindv1alpha2.APIServiceExportRequest) error { exportedSchemas, err := r.getExportedSchemas(ctx, cl) if err != nil { return err } - // Ensure all bound schemas exist for _, res := range req.Spec.Resources { if len(res.Versions) == 0 { continue @@ -153,35 +154,53 @@ func (r *reconciler) ensureBoundSchemas(ctx context.Context, cl client.Client, _ boundSchema.Spec.InformerScope = r.informerScope boundSchema.ResourceVersion = "" - obj, err := r.getBoundSchema(ctx, cl, boundSchema.Namespace, boundSchema.Name) - if err != nil && !apierrors.IsNotFound(err) && !strings.Contains(err.Error(), "no matches for kind") { + if err := r.createOrUpdateBoundSchema(ctx, cl, export, boundSchema); err != nil { return err } + } + } + } - // TODO(mjudeikis): https://github.com/kube-bind/kube-bind/issues/297 - if obj != nil { - continue - } + return nil +} - // If namespaced isolation is configured for cluster-scoped objects, - // we need to rewrite the BoundSchema's scope accordingly. For all - // other isolation strategies, as well as for namespaced schemas, - // no changes are necessary. - if boundSchema.Spec.Scope == apiextensionsv1.NamespaceScoped && r.isolation == kubebindv1alpha2.IsolationNamespaced { - boundSchema.Spec.Scope = apiextensionsv1.ClusterScoped - } +func (r *reconciler) createOrUpdateBoundSchema(ctx context.Context, cl client.Client, export *kubebindv1alpha2.APIServiceExport, desired *kubebindv1alpha2.BoundSchema) error { + logger := klog.FromContext(ctx) - if err := r.createBoundSchema(ctx, cl, boundSchema); err != nil { - return err - } - } + existing, err := r.getBoundSchema(ctx, cl, desired.Namespace, desired.Name) + if err != nil && !apierrors.IsNotFound(err) && !strings.Contains(err.Error(), "no matches for kind") { + return err + } + + if existing != nil { + if export == nil { + return nil + } + if err := controllerutil.SetControllerReference(export, existing, cl.Scheme()); err != nil { + return fmt.Errorf("failed to set owner reference on BoundSchema %s: %w", desired.Name, err) } + if err := r.updateBoundSchema(ctx, cl, existing); err != nil { + return fmt.Errorf("failed to update BoundSchema %s with owner reference: %w", desired.Name, err) + } + logger.V(6).Info("Updated owner reference on existing BoundSchema", + "boundSchema", desired.Name, + "export", export.Name, + "namespace", desired.Namespace) + return nil } - return nil + // If namespaced isolation is configured for cluster-scoped objects, + // we need to rewrite the BoundSchema's scope accordingly. For all + // other isolation strategies, as well as for namespaced schemas, + // no changes are necessary. + if desired.Spec.Scope == apiextensionsv1.NamespaceScoped && r.isolation == kubebindv1alpha2.IsolationNamespaced { + desired.Spec.Scope = apiextensionsv1.ClusterScoped + } + + return r.createBoundSchema(ctx, cl, desired) } -func (r *reconciler) ensureExports(ctx context.Context, cl client.Client, cache cache.Cache, req *kubebindv1alpha2.APIServiceExportRequest) error { +func (r *reconciler) ensureExports(ctx context.Context, cl client.Client, existingExport *kubebindv1alpha2.APIServiceExport, req *kubebindv1alpha2.APIServiceExportRequest) error { logger := klog.FromContext(ctx) var schemas []*kubebindv1alpha2.BoundSchema @@ -209,12 +228,7 @@ func (r *reconciler) ensureExports(ctx context.Context, cl client.Client, cache schemas = append(schemas, boundSchema) } - if _, err := r.getServiceExport(ctx, cache, req.Namespace, req.Name); err != nil { - if !apierrors.IsNotFound(err) { - return err - } - } else { - // already exists; nothing to do + if existingExport != nil { conditions.MarkTrue(req, kubebindv1alpha2.APIServiceExportRequestConditionExportsReady) return nil } diff --git a/backend/options/oidc.go b/backend/options/oidc.go index e8d73ac1c..311de002b 100644 --- a/backend/options/oidc.go +++ b/backend/options/oidc.go @@ -20,7 +20,9 @@ import ( "crypto/tls" "fmt" "net" + "net/url" "os" + "strings" "github.com/spf13/pflag" @@ -122,6 +124,7 @@ func (options *OIDC) Validate() error { if options.CallbackURL == "" { return fmt.Errorf("OIDC callback URL cannot be empty") } + if options.CAFile != "" && options.TLSConfig != nil { return fmt.Errorf("cannot use both CA file and embedded OIDC server") } @@ -130,6 +133,29 @@ func (options *OIDC) Validate() error { return fmt.Errorf("invalid OIDC provider type: %s", options.Type) } + issuerURL, err := url.Parse(options.IssuerURL) + if err != nil { + return fmt.Errorf("--oidc-issuer-url must be a valid URL: %w", err) + } + if issuerURL.Scheme != "http" && issuerURL.Scheme != "https" { + return fmt.Errorf("--oidc-issuer-url must use http or https scheme, got: %s", issuerURL.Scheme) + } + + callbackURL, err := url.Parse(options.CallbackURL) + if err != nil { + return fmt.Errorf("--oidc-callback-url must be a valid URL: %w", err) + } + if callbackURL.Scheme != "http" && callbackURL.Scheme != "https" { + return fmt.Errorf("--oidc-callback-url must use http or https scheme, got: %s", callbackURL.Scheme) + } + if !strings.HasSuffix(callbackURL.Path, "/api/callback") { + return fmt.Errorf("--oidc-callback-url must end with '/api/callback', got path: %s", callbackURL.Path) + } + + if options.Type == string(kubebindv1alpha2.OIDCProviderTypeEmbedded) && !strings.HasSuffix(options.IssuerURL, "/oidc") { + return fmt.Errorf("--oidc-issuer-url must end with '/oidc' when using embedded OIDC provider") + } + if options.Type == string(kubebindv1alpha2.OIDCProviderTypeExternal) && len(options.AllowedGroups) == 0 && len(options.AllowedUsers) == 0 { return fmt.Errorf("when using external OIDC provider, at least one of allowed groups or allowed users must be specified") } diff --git a/backend/options/oidc_test.go b/backend/options/oidc_test.go new file mode 100644 index 000000000..023f4d9bb --- /dev/null +++ b/backend/options/oidc_test.go @@ -0,0 +1,146 @@ +/* +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 options + +import ( + "testing" + + "github.com/stretchr/testify/require" + + kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" +) + +func TestOIDCValidate(t *testing.T) { + tests := []struct { + name string + options *OIDC + wantErr bool + errMsg string + }{ + { + name: "embedded OIDC with valid issuer URL ending in /oidc", + options: &OIDC{ + Type: string(kubebindv1alpha2.OIDCProviderTypeEmbedded), + IssuerClientID: "test-client-id", + IssuerClientSecret: "test-client-secret", + IssuerURL: "http://localhost:8080/oidc", + CallbackURL: "http://localhost:8080/api/callback", + }, + wantErr: false, + }, + { + name: "embedded OIDC with invalid issuer URL not ending in /oidc", + options: &OIDC{ + Type: string(kubebindv1alpha2.OIDCProviderTypeEmbedded), + IssuerClientID: "test-client-id", + IssuerClientSecret: "test-client-secret", + IssuerURL: "http://localhost:8080", + CallbackURL: "http://localhost:8080/api/callback", + }, + wantErr: true, + errMsg: "--oidc-issuer-url must end with '/oidc' when using embedded OIDC provider", + }, + { + name: "embedded OIDC with trailing slash /oidc/", + options: &OIDC{ + Type: string(kubebindv1alpha2.OIDCProviderTypeEmbedded), + IssuerClientID: "test-client-id", + IssuerClientSecret: "test-client-secret", + IssuerURL: "http://localhost:8080/oidc/", + CallbackURL: "http://localhost:8080/api/callback", + }, + wantErr: true, + errMsg: "--oidc-issuer-url must end with '/oidc' when using embedded OIDC provider", + }, + { + name: "external OIDC does not require /oidc suffix", + options: &OIDC{ + Type: string(kubebindv1alpha2.OIDCProviderTypeExternal), + IssuerClientID: "test-client-id", + IssuerClientSecret: "test-client-secret", + IssuerURL: "http://localhost:8080", + CallbackURL: "http://localhost:8080/api/callback", + AllowedGroups: []string{"admins"}, + }, + wantErr: false, + }, + { + name: "malformed issuer URL", + options: &OIDC{ + Type: string(kubebindv1alpha2.OIDCProviderTypeEmbedded), + IssuerClientID: "test-client-id", + IssuerClientSecret: "test-client-secret", + IssuerURL: "not-a-valid-url", + CallbackURL: "http://localhost:8080/api/callback", + }, + wantErr: true, + errMsg: "--oidc-issuer-url must use http or https scheme, got: ", + }, + { + name: "malformed callback URL", + options: &OIDC{ + Type: string(kubebindv1alpha2.OIDCProviderTypeEmbedded), + IssuerClientID: "test-client-id", + IssuerClientSecret: "test-client-secret", + IssuerURL: "http://localhost:8080/oidc", + CallbackURL: "not-a-valid-url", + }, + wantErr: true, + errMsg: "--oidc-callback-url must use http or https scheme, got: ", + }, + { + name: "callback URL with invalid scheme", + options: &OIDC{ + Type: string(kubebindv1alpha2.OIDCProviderTypeEmbedded), + IssuerClientID: "test-client-id", + IssuerClientSecret: "test-client-secret", + IssuerURL: "http://localhost:8080/oidc", + CallbackURL: "ftp://localhost:8080/api/callback", + }, + wantErr: true, + errMsg: "--oidc-callback-url must use http or https scheme, got: ftp", + }, + { + name: "callback URL with only /callback (missing /api prefix)", + options: &OIDC{ + Type: string(kubebindv1alpha2.OIDCProviderTypeEmbedded), + IssuerClientID: "test-client-id", + IssuerClientSecret: "test-client-secret", + IssuerURL: "http://localhost:8080/oidc", + CallbackURL: "http://localhost:8080/callback", + }, + wantErr: true, + errMsg: "--oidc-callback-url must end with '/api/callback', got path: /callback", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.options.Validate() + + if !tt.wantErr { + require.NoError(t, err) + return + } + + require.Error(t, err) + if tt.errMsg != "" { + require.EqualError(t, err, tt.errMsg) + } + }) + } +} diff --git a/cli/pkg/kubectl/dev/plugin/create.go b/cli/pkg/kubectl/dev/plugin/create.go index ebec3a4fb..e495f3acd 100644 --- a/cli/pkg/kubectl/dev/plugin/create.go +++ b/cli/pkg/kubectl/dev/plugin/create.go @@ -262,7 +262,7 @@ func (o *DevOptions) runWithColors(ctx context.Context) error { if providerIP != "" { fmt.Fprintf(o.Streams.ErrOut, "%s\n", blueCommand(fmt.Sprintf("KUBECONFIG=%s.kubeconfig kubectl bind --konnector-host-alias %s:kube-bind.dev.local", o.ConsumerClusterName, providerIP))) } else { - fmt.Fprintf(o.Streams.ErrOut, "%s\n", blueCommand(fmt.Sprintf("PROVIDER_IP=$(docker inspect %s-control-plane | jq -r '.[0].NetworkSettings.Networks[\"%s\"].IPAddress') && KUBECONFIG=%s.kubeconfig kubectl bind --konnector-host-alias ${PROVIDER_IP}:kube-bind.dev.local", o.KindNetwork, o.ProviderClusterName, o.ConsumerClusterName))) + fmt.Fprintf(o.Streams.ErrOut, "%s\n", blueCommand(fmt.Sprintf("PROVIDER_IP=$(docker inspect %s-control-plane | jq -r '.[0].NetworkSettings.Networks[\"%s\"].IPAddress') && KUBECONFIG=%s.kubeconfig kubectl bind --konnector-host-alias ${PROVIDER_IP}:kube-bind.dev.local", o.ProviderClusterName, o.KindNetwork, o.ConsumerClusterName))) } return nil diff --git a/contrib/kcp/bootstrap/config/kcp b/contrib/kcp/bootstrap/config/kcp deleted file mode 120000 index fbc41d9bb..000000000 --- a/contrib/kcp/bootstrap/config/kcp +++ /dev/null @@ -1 +0,0 @@ -../../deploy/ \ No newline at end of file diff --git a/contrib/kcp/bootstrap/server.go b/contrib/kcp/bootstrap/server.go index 8453090f4..773d5a5a1 100644 --- a/contrib/kcp/bootstrap/server.go +++ b/contrib/kcp/bootstrap/server.go @@ -24,7 +24,7 @@ import ( bootstrapconfig "github.com/kube-bind/kube-bind/contrib/kcp/bootstrap/config/config" bootstrapcore "github.com/kube-bind/kube-bind/contrib/kcp/bootstrap/config/core" - bootstrapkubebind "github.com/kube-bind/kube-bind/contrib/kcp/bootstrap/config/kcp" + bootstrapkubebind "github.com/kube-bind/kube-bind/contrib/kcp/deploy" ) type Server struct { diff --git a/contrib/kcp/deploy/bootstrap.go b/contrib/kcp/deploy/bootstrap.go index 23e8c91cb..f660a30dd 100644 --- a/contrib/kcp/deploy/bootstrap.go +++ b/contrib/kcp/deploy/bootstrap.go @@ -33,7 +33,7 @@ import ( "k8s.io/apimachinery/pkg/util/wait" "k8s.io/klog/v2" - "github.com/kube-bind/kube-bind/contrib/kcp/bootstrap/config/kcp/resources" + "github.com/kube-bind/kube-bind/contrib/kcp/deploy/resources" ) //go:embed examples/*.yaml diff --git a/docs/.gitignore b/docs/.gitignore index 85b8a8703..856657629 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1,3 +1,5 @@ venv generated __pycache__ +.cache/ +site/ diff --git a/docs/content/.pages b/docs/content/.pages index 7ae29399e..8af9ab093 100644 --- a/docs/content/.pages +++ b/docs/content/.pages @@ -1,8 +1,9 @@ nav: - Home: - - index.md + - index.md - Setup: setup - Usage: usage - Contributing: contributing - Developers: developers - API References: reference + - Blog: blog diff --git a/docs/content/blog/.authors.yml b/docs/content/blog/.authors.yml new file mode 100644 index 000000000..481c7f08e --- /dev/null +++ b/docs/content/blog/.authors.yml @@ -0,0 +1,5 @@ +authors: + olamilekan000: + name: Olalekan Odukoya + description: Contributor + avatar: https://avatars.githubusercontent.com/u/24735571?v=4 diff --git a/docs/content/blog/index.md b/docs/content/blog/index.md new file mode 100644 index 000000000..c58f16c50 --- /dev/null +++ b/docs/content/blog/index.md @@ -0,0 +1,2 @@ +# Blog + diff --git a/docs/content/blog/posts/2026-02-14-kube-bind-internals.md b/docs/content/blog/posts/2026-02-14-kube-bind-internals.md new file mode 100644 index 000000000..6e0257915 --- /dev/null +++ b/docs/content/blog/posts/2026-02-14-kube-bind-internals.md @@ -0,0 +1,424 @@ +--- +title: "Deep Dive: Understanding kube-bind Internals" +description: "A comprehensive look at how kube-bind works under the hood, covering the Service Provider, Service Consumer, and Konnector architecture." +icon: material/engine +date: 2026-02-14 +authors: + - olamilekan000 +--- + +# Deep Dive: Understanding kube-bind Internals + +> **Note:** Just want to get started quickly? Check out our [Quick Start Guide](2026-02-14-kube-bind-quickstart.md) which sets up everything automatically with `kubectl bind dev create`. This article explains the machinery under the hood—the "Hard Way". + +If you’ve ever tried to make one cluster consume resources from another, you’ve probably had to deal with complicated networking setups, VPN tunnels, duplicated Custom Resource Definitions (CRDs), or custom-built controllers to keep everything in sync. + +As organizations grow, the need for multi-cluster setups become inevitable, and this comes with its own headache especially when you need to share services or resource between clusters. Doing this in Kubernetes is inherently hard because clusters are isolated by design. They don’t natively “talk” to each other. + +This is where kube-bind comes in. + + + +To understand kube-bind, it helps to think about how mobile apps work. + +When a developer builds an app, they don’t send the source code directly to every user. Instead, they publish it to an app marketplace like the App Store. Users can then browse, install, and use the app without needing direct access to the developer’s system. + +kube-bind works in a similar way. + +One Kubernetes cluster can “publish” selected APIs, and another cluster can “install” or bind to them. The consumer cluster doesn’t need full access to the provider cluster, it only interacts with the exported APIs, just like installing an app. + +kube-bind provides a Kubernetes-native way to securely export APIs from one cluster and bind to them in another without stitching clusters together through complex networking configurations or manual synchronization. + +In this article, we’ll explore what kube-bind is, why it exists, and how to use it step-by-step to share services across Kubernetes clusters. + +## What Is kube-bind? + +`kube-bind` is an open-source tool that allows you to export Kubernetes APIs from a **Service Provider** cluster and consume them in a **Consumer** cluster. + +It effectively "projects" an API (like a CRD) from one cluster to another. To the consumer, it feels like the resource is local. They can create a `PostgreSQL` object in their namespace and interact with it just like any other Kubernetes resource, however, the actual logic and heavy operational workload happens on the provider's side. + +```mermaid +graph LR + subgraph Provider Cluster + A[PostgreSQL Controller] --> B[Actual Postgres DB] + C[APIServiceExport] + end + + subgraph Consumer Cluster + D[Konnector] + E[Shadow CRD] + end + + D <-->|Syncs Resources| C + E -.->|User Creates Resource| D +``` + +> `kube-bind` focuses on **service binding**. It does not merge or federate control planes. Instead, it allows one cluster to securely consume specific APIs exported by another. + +## Core Concepts & API Objects + +Under the hood, `kube-bind` uses a set of Custom Resource Definitions (CRDs) to manage the lifecycle of exported services and bindings. Here are the key objects you'll encounter: + +- **`APIServiceExport`**: Represents a specific API (CRD) that the provider wants to export. An export makes the API available to bind. +- **`APIServiceExportTemplate`**: A template used to instantiate the `APIServiceExport`. It defines what resources can be exported and carries configuration for the export. +- **`ClusterBinding`**: A resource on the consumer side that represents the connection to a specific provider. It holds the authentication details and endpoint information. +- **`APIServiceBinding`**: A resource on the consumer side that represents a binding to a specific exported API. When created, it triggers the synchronization process. +- **`Konnector`**: The lightweight agent running in the consumer cluster. It watches `APIServiceBinding` resources and handles the actual synchronization of data between the consumer and provider. + +## The "Hard Way": Manual Setup & Architecture + +To really understand how `kube-bind` works, we're going to set it up manually. This is what the `dev create` command does automatically, but looking at the individual steps reveals the architecture. + +Let's see this in action. We'll simulate both the provider and consumer on your local machine. + +### Prerequisites + +You'll need: + +- [kind](https://kind.sigs.k8s.io/) +- [kubectl](https://kubernetes.io/docs/tasks/tools/) +- [kube-bind CLI](https://github.com/kube-bind/kube-bind/releases) + +### Install kube-bind + +The easiest way to install the `kube-bind` CLI is via [krew](https://krew.sigs.k8s.io/), the plugin manager for `kubectl`. Since `kube-bind` is in its own index, you need to add it first: + +```bash +kubectl krew index add bind https://github.com/kube-bind/krew-index.git +kubectl krew install bind/bind +``` + +You should see output similar to this: + +```text +➜ kubectl krew index add bind https://github.com/kube-bind/krew-index.git +WARNING: You have added a new index from "https://github.com/kube-bind/krew-index.git" +The plugins in this index are not audited for security by the Krew maintainers. +Install them at your own risk. + +➜ kubectl krew install bind/bind +Updated the local copy of plugin index "bind". +Installing plugin: bind +Installed plugin: bind +\ + | Use this plugin: + | kubectl bind + | Documentation: + | https://kube-bind.io/ +/ +``` + +If you don't have krew installed, you can download the binary directly from the [releases page](https://github.com/kube-bind/kube-bind/releases). + +### Step 1: Create the Clusters + +We need at least two clusters, but first, we'll start by creating a **provider** cluster. + +**Create the provider cluster with port mapping:** + +```bash +cat <> /etc/hosts" +``` + +**Windows**: +Add `127.0.0.1 kube-bind-backend.kube-bind.svc` to `C:\Windows\System32\drivers\etc\hosts`. + +Now, install the backend using Helm, configuring it to use this domain: + +```bash +kubectl config use-context kind-provider + +# Install the backend using Helm +helm upgrade --install \ + --namespace kube-bind \ + --create-namespace \ + --set image.tag=v0.7.0 \ + --set backend.externalAddress=https://provider-control-plane:6443 \ + --set backend.tlsExternalServerName=kubernetes.default.svc \ + --set backend.oidc.issuerUrl=http://kube-bind-backend.kube-bind.svc:8080/oidc \ + --set backend.oidc.callbackUrl=http://kube-bind-backend.kube-bind.svc:8080/api/callback \ + kube-bind oci://ghcr.io/kube-bind/charts/backend --version 0.7.0 +``` + +> **Note:** The OIDC configuration used here (which uses the built-in mock OIDC provider of `kube-bind-backend`) is for demonstration purposes only. For a production deployment, you must integrate with a real OIDC provider. See [Installation with Helm](../../setup/helm.md) for production guidelines. + +> **Important Configuration Details:** +> +> - `backend.externalAddress=https://provider-control-plane:6443` - This is the address the Konnector will use to connect to the provider's API server. +> - `backend.tlsExternalServerName=kubernetes.default.svc` - This ensures TLS verification succeeds even though we're connecting via a custom hostname. +> - `backend.oidc.callbackUrl` - Must include the `http://` scheme to ensure proper browser redirects during authentication. + +After installing, verify that the backend is running: + +```bash +kubectl get po -n kube-bind +``` + +You should see something like this: + +```text +NAME READY STATUS RESTARTS AGE +kube-bind-backend-6779b5b99f-vczmq 1/1 Running 0 18m +``` + +Since we are running locally in Kind without an Ingress, we need to port-forward the backend service. + +**Open a new terminal window** and run: + +```bash +kubectl port-forward svc/kube-bind-backend -n kube-bind 8080:8080 --context kind-provider +``` + +![Kube Bind Authentication Required](images/auth-required.png) +_If you visit `http://kube-bind-backend.kube-bind.svc:8080` in your browser, you'll see this screen. This is normal and confirms the backend is running!_ + +### Step 3: Exporting an API Service + +Now that the backend is running, we can export an API. We'll use the **Cowboy** CRD included in the `kube-bind` repository as our example. + +First, apply the CRD to the **provider** cluster: + +```bash +kubectl apply -f https://raw.githubusercontent.com/kube-bind/kube-bind/main/deploy/examples/crd-cowboys.yaml +``` + +Verify that the CRD is installed: + +```bash +kubectl get crd +``` + +You should see `cowboys.wildwest.dev` in the list: + +```text +NAME CREATED AT +apiserviceexports.kube-bind.io 2026-02-14T13:04:56Z +... +cowboys.wildwest.dev 2026-02-14T13:57:13Z +``` + +Now, we need to **export** this service so consumers can find it. We do this by creating an `APIServiceExportTemplate`. This tells the backend to list "Cowboys" in its service catalog. + +```bash +kubectl apply -f https://raw.githubusercontent.com/kube-bind/kube-bind/main/deploy/examples/template-cowboys.yaml +``` + +Verify that the template is created: + +```bash +kubectl get apiserviceexporttemplates +``` + +Output: + +```text +NAME RESOURCES PERMISSIONCLAIMS AGE +cowboys wildwest.dev secrets 12s +``` + +The service is now exported! + +### Step 4: Bind the Consumer + +Switch to the **consumer** cluster. + +```bash +kubectl config use-context kind-consumer +``` + +Run the login command to authenticate with the provider: + +```bash +kubectl bind login http://kube-bind-backend.kube-bind.svc:8080 +``` + +You should see output similar to: + +```text +Connecting to kube-bind server http://kube-bind-backend.kube-bind.svc:8080... +Started local callback server at http://127.0.0.1:64184/callback +Opening browser for authentication... +🔑 Successfully authenticated to kube-bind-backend.kube-bind.svc:8080 +Configuration saved to: /Users/username/.kube-bind/config +``` + +Now run the bind command: + +```bash +kubectl bind http://kube-bind-backend.kube-bind.svc:8080 +``` + +This will open your browser showing the available services in the Provider's template catalog. Amongst them, you'll see the **Cowboys** template that we exported earlier. + +![Available Resources - Cowboys Template](images/cowboys_template_ui.png) + +Click **"Bind for CLI"** to proceed. After clicking, your browser will show a success message: + +![Binding Completed Successfully](images/binding_success.png) + +Meanwhile, back in your terminal, you'll see the CLI deploying the Konnector: + +```text +🌐 Opening kube-bind UI in your browser... +Browser opened successfully +Waiting for binding completion from UI... + (Press Ctrl+C to cancel) + +Binding completed successfully! +Created kube-bind namespace. +🔒 Created secret kube-bind/kubeconfig-9lbzx for host https://provider-control-plane:6443, namespace kube-bind-enkvby5uzkct +🚀 Deploying konnector v0.7.0 to namespace kube-bind. + Waiting for the konnector to be ready................. +✅ Created APIServiceBinding cowboys for 1 resources +Created 1 APIServiceBinding(s): + - cowboys +Resources bound successfully! +``` + +Behind the scenes, the CLI created a `kube-bind` namespace, an `APIServiceBinding`, deploys the `konnector` and creates some other resource in the consumer cluster. The `APIServiceBinding` resource tells the Konnector which services to sync from the provider. + +Check available namespaces + +```bash +kubectl get ns +``` + +You should see the `kube-bind` namespace: + +```text +NAME STATUS AGE +default Active 6h6m +kube-bind Active 92s +kube-node-lease Active 6h6m +kube-public Active 6h6m +kube-system Active 6h6m +local-path-storage Active 6h6m +``` + +Check the Konnector pods: + +```bash +kubectl get po -n kube-bind +``` + +Output: +We can see the konnector pods running in the `kube-bind` namespace. + +```text +NAME READY STATUS RESTARTS AGE +konnector-547ff86976-5rmh8 1/1 Running 0 98s +konnector-547ff86976-xkcdm 1/1 Running 0 98s +``` + +### Step 5: Verify and Use + +After the binding completes, several CRDs will be installed in your consumer cluster. Let's verify: + +```bash +kubectl get crd +``` + +You should see the kube-bind infrastructure CRDs along with the Cowboys CRD: + +```text +NAME CREATED AT +apiservicebindingbundles.kube-bind.io 2026-02-14T23:37:29Z +apiservicebindings.kube-bind.io 2026-02-14T23:37:29Z +cowboys.wildwest.dev 2026-02-14T23:37:37Z +``` + +The `cowboys.wildwest.dev` CRD is now available locally! The other CRDs are part of the kube-bind infrastructure that manages the binding lifecycle. + +Check that the **Cowboy** CRD is available: + +```bash +kubectl get crd cowboys.wildwest.dev +``` + +Now, you can create a `Cowboy` resource directly in your consumer cluster. The magic is that this resource will be managed by the provider! + +```bash +kubectl apply -f - < ProviderAPI + end + + subgraph Consumer["Consumer Cluster"] + Konnector["Konnector Agent"] + BoundAPI["MangoDB CRD
Synced Copy"] + Konnector --> BoundAPI + end + + User -->|"1. kubectl bind dev create"| CLI + CLI -.->|"Creates & Installs"| Backend + CLI -.->|"Creates Cluster"| Consumer + + User -->|"2. kubectl bind login"| Backend + Backend -->|"Auth Token"| User + + User -->|"3. kubectl bind create
Select API in UI"| CLI + CLI -->|"Install Konnector"| Konnector + + Konnector <-->|"4. Syncs Resources"| Backend + + User -.->|"5. kubectl apply/get"| BoundAPI + BoundAPI -.->|"Synced to"| ProviderAPI +``` + + + +In this guide, we'll get you up and running with `kube-bind` in minutes. We'll use the `dev create` command to automatically provision a local playground with a Provider and Consumer cluster. + +> **Note:** If you want to understand how `kube-bind` works internally or set it up manually for production, check out our deep dive: [Understanding kube-bind Internals](2026-02-14-kube-bind-internals.md). + +## Prerequisites + +You'll need: + +- [Docker](https://www.docker.com/) running. +- [kubectl](https://kubernetes.io/docs/tasks/tools/). +- `kube-bind` CLI installed. + +### Install kube-bind + +The easiest way is via [krew](https://krew.sigs.k8s.io/): + +```bash +kubectl krew index add bind https://github.com/kube-bind/krew-index.git +kubectl krew install bind/bind +``` + +## Step 1: Create the Environment + +Run the following command to spin up the entire demo environment: + +```bash +kubectl bind dev create +``` + +> **Note:** The `dev create` command sets up a mock OIDC provider for testing purposes. This is **not** suitable for production. For a production-ready deployment with real OIDC integration, please refer to our [Installation with Helm](../../setup/helm.md) guide. + +This will: + +1. Create a **Provider** cluster (`kind-provider`). +2. Create a **Consumer** cluster (`kind-consumer`). +3. Install the `kube-bind-backend` on the provider. +4. Configure networking and OIDC mock services. + +Once it finishes, you'll see output like this: + +```text +kube-bind Development Environment Setup + +EXPERIMENTAL: kube-bind dev command is in preview +Requirements: Docker must be installed and running + +Warning: Could not automatically add host entry. Please run: + echo '127.0.0.1 kube-bind.dev.local' | sudo tee -a /etc/hosts + +Creating kind cluster kind-provider with network kube-bind-dev +Kind cluster kind-provider created +Helm chart installed successfully + +Creating kind cluster kind-consumer with network kube-bind-dev +Kind cluster kind-consumer created +kube-bind dev environment is ready! + +Configuration: +• Provider cluster kubeconfig: kind-provider.kubeconfig +• Consumer cluster kubeconfig: kind-consumer.kubeconfig +• kube-bind server URL: http://kube-bind.dev.local:8080 + +Next Steps: + +1. Add to /etc/hosts (if not already done): +echo '127.0.0.1 kube-bind.dev.local' | sudo tee -a /etc/hosts + +2. Login to authenticate to the provider cluster: +kubectl bind login http://kube-bind.dev.local:8080 + +3. Bind an API service from provider to consumer: +PROVIDER_IP=$(docker inspect kind-provider-control-plane | jq -r '.[0].NetworkSettings.Networks["kube-bind-dev"].IPAddress') && KUBECONFIG=kind-consumer.kubeconfig kubectl bind --konnector-host-alias ${PROVIDER_IP}:kube-bind.dev.local +``` + +## Step 2: Authenticate + +Copy and run the login command provided in the output: + +```bash +kubectl bind login http://kube-bind.dev.local:8080 +``` + +Output: + +```text +Connecting to kube-bind server http://kube-bind.dev.local:8080... +Started local callback server at http://127.0.0.1:56642/callback +Opening browser for authentication... +🔑 Successfully authenticated to kube-bind.dev.local:8080 +Configuration saved to: /Users/olalekanodukoya/.kube-bind/config +``` + +This will open your browser to authenticate. Since this is a dev environment, just follow the prompts. + +## Step 3: Bind a Service + +Now, switch to the consumer role and bind to a service. Copy the second command from the `dev create` output. It will look something like this: + +```bash +PROVIDER_IP=$(docker inspect kind-provider-control-plane | jq -r '.[0].NetworkSettings.Networks["kube-bind-dev"].IPAddress') && KUBECONFIG=kind-consumer.kubeconfig kubectl bind --konnector-host-alias ${PROVIDER_IP}:kube-bind.dev.local +``` + +This will open a web interface where you can choose which API to bind. Select **mangodb** and click **Bind for CLI**. + +![Binding UI](../../images/mangodb-bind-ui.png) + +Output: + +```text +🌐 Opening kube-bind UI in your browser... +Browser opened successfully +Waiting for binding completion from UI... + (Press Ctrl+C to cancel) + +Binding completed successfully! +Created kube-bind namespace. +🔒 Created secret kube-bind/kubeconfig-tjm2k for host https://kube-bind.dev.local:6443, namespace kube-bind-3iwzhtescg5o0 +🚀 Deploying konnector v0.7.0 to namespace kube-bind. + Waiting for the konnector to be ready................. +✅ Created APIServiceBinding mangodb for 1 resources +Created 1 APIServiceBinding(s): + - mangodb +Resources bound successfully! +``` + +You can verify the new CRDs are available: + +```bash +kubectl get crd +``` + +Output: + +```text +NAME CREATED AT +apiservicebindingbundles.kube-bind.io 2026-02-17T18:50:02Z +apiservicebindings.kube-bind.io 2026-02-17T18:50:02Z +mangodbs.mangodb.com 2026-02-17T18:50:13Z +``` + +## Step 4: Use the Service + +Once the binding is complete, you can interact with the `MangoDB` resource directly from your consumer cluster! + +```bash +export KUBECONFIG=kind-consumer.kubeconfig + +# Create a MangoDB resource +kubectl apply -f - < Concepts - navigation.indexes @@ -26,30 +26,32 @@ theme: - content.code.copy # Enable annotations on specific lines in code blocks - content.code.annotate + # Integrate TOC into navigation sidebar + - toc.integrate logo: logo.svg - favicon: favicons/favicon.ico + favicon: logo.svg palette: - # Palette toggle for automatic mode - - media: "(prefers-color-scheme)" - toggle: - icon: material/brightness-auto - name: Switch to light mode + # Palette toggle for automatic mode + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Switch to light mode - # Palette toggle for light mode - - media: "(prefers-color-scheme: light)" - scheme: default - primary: white - toggle: - icon: material/brightness-7 - name: Switch to dark mode + # Palette toggle for light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: white + toggle: + icon: material/brightness-7 + name: Switch to dark mode - # Palette toggle for dark mode - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: black - toggle: - icon: material/brightness-4 - name: Switch to system preference + # Palette toggle for dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: black + toggle: + icon: material/brightness-4 + name: Switch to system preference extra: version: @@ -62,27 +64,45 @@ extra: - icon: fontawesome/brands/slack link: https://kubernetes.slack.com/archives/C021U8WSAFK + image: high-level.png + title: kube-bind + description: "Bind APIs from other clusters." + plugins: + - social: + cards_layout: default/variant + cards_layout_options: + background_color: "#1a1a1a" + color: "#ffffff" + font_family: Roboto # https://github.com/lukasgeiter/mkdocs-awesome-pages-plugin # Greater control over how navigation links are shown - awesome-pages + # Blog support + - blog: + archive: true + archive_name: "Recent Posts" + categories: false + archive_toc: true + blog_toc: true + # Docs site search - search # Use Jinja macros in .md files - macros: - include_dir: 'overrides' - module_name: 'main' + include_dir: "overrides" + module_name: "main" # Configure multiple language support - - i18n: - docs_structure: suffix - fallback_to_default: true - languages: - - build: true - default: true - locale: en - name: English - reconfigure_material: true - reconfigure_search: true + # - i18n: + # docs_structure: suffix + # fallback_to_default: true + # languages: + # - build: true + # default: true + # locale: en + # name: English + # reconfigure_material: true + # reconfigure_search: true # Configure multi-version plugin - mike: alias_type: redirect @@ -101,8 +121,10 @@ markdown_extensions: custom_fences: - name: mermaid class: mermaid - format: !!python/name:pymdownx.superfences.fence_code_format - # Enable note/warning/etc. callouts + format: + !!python/name:pymdownx.superfences.fence_code_format # Enable note/warning/etc. callouts + + - admonition # Enable content tabs - pymdownx.tabbed: diff --git a/docs/requirements.txt b/docs/requirements.txt index ae43afa1e..ee42d703c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,9 +2,9 @@ mike==2.1.3 mkdocs==1.6.1 mkdocs-awesome-pages-plugin==2.9.2 mkdocs-macros-plugin==1.0.5 -mkdocs-material==9.5.49 +mkdocs-material[imaging]>=9.7.1 mkdocs-material-extensions==1.3.1 -mkdocs-static-i18n==1.2.2 +mkdocs-static-i18n==1.3.0 # https://github.com/mkdocs/mkdocs/issues/4032 click<=8.2.1 diff --git a/test/e2e/bind/happy-case_test.go b/test/e2e/bind/happy-case_test.go index 64106f3a2..fcb87739d 100644 --- a/test/e2e/bind/happy-case_test.go +++ b/test/e2e/bind/happy-case_test.go @@ -448,6 +448,44 @@ func testHappyCase( require.NotEqual(t, consumer.providerContractNamespace, "unknown") }, }, + { + name: "verify BoundSchemas have owner references to APIServiceExport", + step: func(t *testing.T) { + t.Logf("Verifying BoundSchemas have owner references to APIServiceExport") + export, err := providerBindClient.KubeBindV1alpha2().APIServiceExports(consumer.providerContractNamespace).Get(ctx, "test-binding", metav1.GetOptions{}) + if errors.IsNotFound(err) && consumer.name != "consumer1" { + t.Skip("APIServiceExport already deleted by another consumer") + } + require.NoError(t, err, "APIServiceExport should exist") + + boundSchemas, err := providerBindClient.KubeBindV1alpha2().BoundSchemas(consumer.providerContractNamespace).List(ctx, metav1.ListOptions{}) + require.NoError(t, err, "should be able to list BoundSchemas") + require.NotEmpty(t, boundSchemas.Items, "should have at least one BoundSchema") + + // Verify each BoundSchema has an owner reference to the APIServiceExport + for _, boundSchema := range boundSchemas.Items { + t.Logf("Checking owner reference for BoundSchema %s", boundSchema.Name) + + var hasOwnerRef bool + for _, ownerRef := range boundSchema.OwnerReferences { + if ownerRef.Kind == "APIServiceExport" && + ownerRef.Name == export.Name && + ownerRef.UID == export.UID && + ownerRef.Controller != nil && + *ownerRef.Controller { + hasOwnerRef = true + break + } + } + + require.True(t, hasOwnerRef, + "BoundSchema %s should have controller owner reference to APIServiceExport %s", + boundSchema.Name, export.Name) + } + + t.Logf("All BoundSchemas have proper owner references") + }, + }, // Request included namespace, so we check it first { name: "verify provider side namespace pre-seeding and RBAC management", diff --git a/web/package-lock.json b/web/package-lock.json index 692ac2f56..e8bf669f3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,7 +8,7 @@ "name": "kube-bind-frontend", "version": "1.0.0", "dependencies": { - "axios": "^1.5.0", + "axios": "^1.13.5", "vue": "^3.3.4", "vue-router": "^4.2.4" }, @@ -1159,13 +1159,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", - "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -1866,9 +1866,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", diff --git a/web/package.json b/web/package.json index 41d04f598..0fbd6ac9c 100644 --- a/web/package.json +++ b/web/package.json @@ -15,7 +15,7 @@ "dependencies": { "vue": "^3.3.4", "vue-router": "^4.2.4", - "axios": "^1.5.0" + "axios": "^1.13.5" }, "devDependencies": { "@types/node": "^20.8.0",