@@ -18,6 +18,7 @@ package http
1818
1919import (
2020 "context"
21+ "encoding/base64"
2122 "encoding/json"
2223 "errors"
2324 "fmt"
@@ -29,12 +30,15 @@ import (
2930 apierrors "k8s.io/apimachinery/pkg/api/errors"
3031 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3132 "k8s.io/apimachinery/pkg/runtime"
33+ "k8s.io/apimachinery/pkg/runtime/serializer"
34+ kjson "k8s.io/apimachinery/pkg/runtime/serializer/json"
3235 componentbaseversion "k8s.io/component-base/version"
3336 "k8s.io/klog/v2"
3437
3538 "github.com/kube-bind/kube-bind/backend/auth"
3639 "github.com/kube-bind/kube-bind/backend/client"
3740 "github.com/kube-bind/kube-bind/backend/kubernetes"
41+ kuberesources "github.com/kube-bind/kube-bind/backend/kubernetes/resources"
3842 "github.com/kube-bind/kube-bind/backend/oidc"
3943 "github.com/kube-bind/kube-bind/backend/session"
4044 "github.com/kube-bind/kube-bind/backend/spaserver"
@@ -149,6 +153,10 @@ func (h *handler) AddRoutes(mux *mux.Router) error {
149153 // Public API routes (no authentication required)
150154 mux .HandleFunc ("/api/healthz" , h .handleHealthz ).Methods (http .MethodGet )
151155 mux .HandleFunc ("/api/bindable-resources" , h .handleBindableResources ).Methods (http .MethodGet )
156+ // Intentionally unauthenticated: serves static, deterministic deployment YAML
157+ // (konnector image tag is the only variable, derived from the server's own version).
158+ // No secrets or cluster-specific data are included.
159+ mux .HandleFunc ("/api/konnector-manifests" , h .handleKonnectorManifests ).Methods (http .MethodGet )
152160
153161 // Generic authentication routes (support both UI and CLI)
154162 mux .HandleFunc ("/api/authorize" , h .authHandler .HandleAuthorize ).Methods (http .MethodGet , http .MethodPost )
@@ -166,6 +174,8 @@ func (h *handler) AddRoutes(mux *mux.Router) error {
166174 apiRouter .Handle ("/collections" , auth .RequireAuth (http .HandlerFunc (h .handleCollections ))).Methods (http .MethodGet )
167175 apiRouter .Handle ("/bind" , auth .RequireAuth (http .HandlerFunc (h .handleBind ))).Methods (http .MethodPost )
168176 apiRouter .Handle ("/ping" , auth .RequireAuth (http .HandlerFunc (h .handlePing ))).Methods (http .MethodGet )
177+ apiRouter .Handle ("/consumer-status" , auth .RequireAuth (http .HandlerFunc (h .handleConsumerStatus ))).Methods (http .MethodGet )
178+ apiRouter .Handle ("/apply-binding" , auth .RequireAuth (http .HandlerFunc (h .handleApplyBinding ))).Methods (http .MethodPost )
169179
170180 if h .oidcServer != nil {
171181 h .oidcServer .AddRoutes (mux )
@@ -197,6 +207,49 @@ func (h *handler) handlePing(w http.ResponseWriter, r *http.Request) {
197207 w .Write ([]byte ("pong" )) //nolint:errcheck
198208}
199209
210+ // handleKonnectorManifests returns the pre-rendered konnector YAML manifests
211+ // that a consumer cluster needs to apply to deploy the konnector agent.
212+ // The manifests are generated from the same Go structs used by the one-click
213+ // apply flow (ensureKonnector) to avoid definition drift.
214+ func (h * handler ) handleKonnectorManifests (w http.ResponseWriter , r * http.Request ) {
215+ prepareNoCache (w )
216+
217+ konnectorVersion , err := bindversion .BinaryVersion (componentbaseversion .Get ().GitVersion )
218+ if err != nil {
219+ konnectorVersion = "latest"
220+ }
221+ konnectorImage := fmt .Sprintf ("ghcr.io/kube-bind/konnector:%s" , konnectorVersion )
222+
223+ manifests := kuberesources .NewKonnectorManifests (konnectorImage , nil )
224+
225+ // Serialize each object to YAML and join with document separators
226+ s := runtime .NewScheme ()
227+ kuberesources .AddKonnectorSchemes (s )
228+ encoder := kjson .NewSerializerWithOptions (
229+ kjson .DefaultMetaFactory ,
230+ s ,
231+ s ,
232+ kjson.SerializerOptions {Yaml : true , Pretty : true , Strict : false },
233+ )
234+ codec := serializer .NewCodecFactory (s ).EncoderForVersion (encoder , nil )
235+
236+ var buf strings.Builder
237+ objects := manifests .Objects ()
238+ for i , obj := range objects {
239+ if i > 0 {
240+ buf .WriteString ("---\n " )
241+ }
242+ if err := codec .Encode (obj , & buf ); err != nil {
243+ writeErrorResponse (w , http .StatusInternalServerError , kubebindv1alpha2 .ErrorCodeInternalError , "Failed to serialize konnector manifests" , err .Error ())
244+ return
245+ }
246+ }
247+
248+ w .Header ().Set ("Content-Type" , "text/yaml" )
249+ w .Header ().Set ("Content-Disposition" , "attachment; filename=konnector.yaml" )
250+ w .Write ([]byte (buf .String ())) //nolint:errcheck
251+ }
252+
200253func (h * handler ) handleLogout (w http.ResponseWriter , r * http.Request ) {
201254 prepareNoCache (w )
202255
@@ -368,7 +421,8 @@ func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) {
368421 }
369422
370423 // Resolve the UI sentinel to a real identity derived from the authenticated session.
371- if identity == auth .UIIdentity {
424+ isUIFlow := identity == auth .UIIdentity
425+ if isUIFlow {
372426 identity = state .Token .Issuer + "/" + state .Token .Subject
373427 logger .Info ("Resolved ui-identity from session" , "identity" , identity )
374428 }
@@ -411,6 +465,26 @@ func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) {
411465 },
412466 }
413467
468+ // For UI-only flow, create the APIServiceExportRequest on the provider cluster
469+ // and wait for reconciliation. In CLI flow the konnector handles this instead.
470+ var exportRequestName string
471+ if isUIFlow {
472+ exportRequest , err := h .kubeManager .CreateAPIServiceExportRequest (
473+ r .Context (),
474+ params .ClusterID ,
475+ handleResult .Namespace ,
476+ bindRequest .Name ,
477+ request .Spec ,
478+ )
479+ if err != nil {
480+ logger .Error (err , "failed to create APIServiceExportRequest" )
481+ statusCode , code , details := mapErrorToCode (err )
482+ writeErrorResponse (w , statusCode , code , "Failed to create API service export request" , details )
483+ return
484+ }
485+ exportRequestName = exportRequest .Name
486+ }
487+
414488 // callback response
415489 requestBytes , err := json .Marshal (& request )
416490 if err != nil {
@@ -430,8 +504,10 @@ func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) {
430504 ID : state .Token .Issuer + "/" + state .Token .Subject ,
431505 },
432506 },
433- Kubeconfig : handleResult .Kubeconfig ,
434- Requests : []runtime.RawExtension {{Raw : requestBytes }},
507+ Kubeconfig : handleResult .Kubeconfig ,
508+ Requests : []runtime.RawExtension {{Raw : requestBytes }},
509+ ProviderNamespace : handleResult .Namespace ,
510+ BindingName : exportRequestName ,
435511 }
436512
437513 payload , err := json .Marshal (& response )
@@ -445,7 +521,120 @@ func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) {
445521 w .Write (payload ) //nolint:errcheck
446522}
447523
448- // listTemplates fetches the list of APIServiceExportTemplates from the backend cluster without checking
524+ // handleConsumerStatus returns whether the authenticated user already has a consumer
525+ // namespace with existing APIServiceExports on the provider.
526+ func (h * handler ) handleConsumerStatus (w http.ResponseWriter , r * http.Request ) {
527+ logger := getLogger (r )
528+ params := client .GetQueryParams (r )
529+ prepareNoCache (w )
530+
531+ authCtx := auth .GetAuthContext (r .Context ())
532+ state := authCtx .SessionState
533+ identity := state .Token .Issuer + "/" + state .Token .Subject
534+
535+ status , err := h .kubeManager .GetConsumerStatus (r .Context (), identity , params .ClusterID )
536+ if err != nil {
537+ logger .Error (err , "failed to get consumer status" )
538+ writeErrorResponse (w , http .StatusInternalServerError , kubebindv1alpha2 .ErrorCodeInternalError , "Failed to get consumer status" , err .Error ())
539+ return
540+ }
541+
542+ payload , err := json .Marshal (status )
543+ if err != nil {
544+ logger .Error (err , "failed to marshal consumer status" )
545+ writeErrorResponse (w , http .StatusInternalServerError , kubebindv1alpha2 .ErrorCodeInternalError , "Failed to marshal consumer status" , err .Error ())
546+ return
547+ }
548+
549+ w .Header ().Set ("Content-Type" , "application/json" )
550+ w .Write (payload ) //nolint:errcheck
551+ }
552+
553+ // applyBindingRequest is the JSON body for the apply-binding endpoint.
554+ type applyBindingRequest struct {
555+ // ConsumerKubeconfig is the base64-encoded kubeconfig for the consumer cluster.
556+ ConsumerKubeconfig string `json:"consumerKubeconfig"`
557+ // BindingName is the name for the binding (used for secret and bundle naming).
558+ BindingName string `json:"bindingName"`
559+ }
560+
561+ // handleApplyBinding receives a consumer kubeconfig and applies the konnector + binding
562+ // bundle to the consumer cluster.
563+ func (h * handler ) handleApplyBinding (w http.ResponseWriter , r * http.Request ) {
564+ logger := getLogger (r )
565+ params := client .GetQueryParams (r )
566+ prepareNoCache (w )
567+
568+ authCtx := auth .GetAuthContext (r .Context ())
569+ state := authCtx .SessionState
570+ identity := state .Token .Issuer + "/" + state .Token .Subject
571+
572+ // Parse request body
573+ const maxBodySize = 1 << 20 // 1 MB
574+ r .Body = http .MaxBytesReader (w , r .Body , maxBodySize )
575+ var req applyBindingRequest
576+ if err := json .NewDecoder (r .Body ).Decode (& req ); err != nil {
577+ writeErrorResponse (w , http .StatusBadRequest , kubebindv1alpha2 .ErrorCodeBadRequest , "Invalid request body" , err .Error ())
578+ return
579+ }
580+
581+ if req .ConsumerKubeconfig == "" {
582+ writeErrorResponse (w , http .StatusBadRequest , kubebindv1alpha2 .ErrorCodeBadRequest , "Missing consumer kubeconfig" , "consumerKubeconfig is required" )
583+ return
584+ }
585+ if req .BindingName == "" {
586+ writeErrorResponse (w , http .StatusBadRequest , kubebindv1alpha2 .ErrorCodeBadRequest , "Missing binding name" , "bindingName is required" )
587+ return
588+ }
589+
590+ // Decode base64 consumer kubeconfig
591+ consumerKubeconfigData , err := base64 .StdEncoding .DecodeString (req .ConsumerKubeconfig )
592+ if err != nil {
593+ writeErrorResponse (w , http .StatusBadRequest , kubebindv1alpha2 .ErrorCodeBadRequest , "Invalid consumer kubeconfig encoding" , "consumerKubeconfig must be base64 encoded" )
594+ return
595+ }
596+
597+ // Get the provider kubeconfig for this user's namespace
598+ handleResult , err := h .kubeManager .HandleResources (r .Context (), state .Token .Subject , identity , params .ClusterID )
599+ if err != nil {
600+ logger .Error (err , "failed to handle resources for apply-binding" )
601+ statusCode , code , details := mapErrorToCode (err )
602+ writeErrorResponse (w , statusCode , code , "Failed to prepare provider resources" , details )
603+ return
604+ }
605+
606+ // Resolve konnector image
607+ konnectorVersion , err := bindversion .BinaryVersion (componentbaseversion .Get ().GitVersion )
608+ if err != nil {
609+ konnectorVersion = "latest"
610+ }
611+ konnectorImage := fmt .Sprintf ("ghcr.io/kube-bind/konnector:%s" , konnectorVersion )
612+
613+ // Apply to consumer cluster
614+ result , err := h .kubeManager .ApplyToConsumer (
615+ r .Context (),
616+ consumerKubeconfigData ,
617+ handleResult .Kubeconfig ,
618+ req .BindingName ,
619+ konnectorImage ,
620+ )
621+ if err != nil {
622+ logger .Error (err , "failed to apply binding to consumer cluster" )
623+ writeErrorResponse (w , http .StatusInternalServerError , kubebindv1alpha2 .ErrorCodeInternalError , "Failed to apply binding to consumer cluster" , err .Error ())
624+ return
625+ }
626+
627+ payload , err := json .Marshal (result )
628+ if err != nil {
629+ logger .Error (err , "failed to marshal apply result" )
630+ writeErrorResponse (w , http .StatusInternalServerError , kubebindv1alpha2 .ErrorCodeInternalError , "Failed to marshal result" , err .Error ())
631+ return
632+ }
633+
634+ w .Header ().Set ("Content-Type" , "application/json" )
635+ w .Write (payload ) //nolint:errcheck
636+ }
637+
449638// if they are part of a Collection or not.
450639func (h * handler ) listTemplates (ctx context.Context , cluster string ) (* kubebindv1alpha2.APIServiceExportTemplateList , error ) {
451640 templates , err := h .kubeManager .ListTemplates (ctx , cluster )
0 commit comments