Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -46,16 +47,21 @@ 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
deleteServiceExportRequest func(ctx context.Context, cl client.Client, namespace, name string) error
}

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)
}
Expand All @@ -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)
}
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
26 changes: 26 additions & 0 deletions backend/options/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import (
"crypto/tls"
"fmt"
"net"
"net/url"
"os"
"strings"

"github.com/spf13/pflag"

Expand Down Expand Up @@ -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")
}
Expand All @@ -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")
}
Expand Down
146 changes: 146 additions & 0 deletions backend/options/oidc_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
2 changes: 1 addition & 1 deletion cli/pkg/kubectl/dev/plugin/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion contrib/kcp/bootstrap/config/kcp

This file was deleted.

2 changes: 1 addition & 1 deletion contrib/kcp/bootstrap/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion contrib/kcp/deploy/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
venv
generated
__pycache__
.cache/
site/
Loading