Skip to content

Commit f3f89e7

Browse files
authored
Merge pull request #508 from cnvergence/add-ui-binding-flow
Add UI binding flow
2 parents 615240f + 3a871e7 commit f3f89e7

11 files changed

Lines changed: 2427 additions & 522 deletions

File tree

backend/http/handler.go

Lines changed: 193 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package http
1818

1919
import (
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+
200253
func (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.
450639
func (h *handler) listTemplates(ctx context.Context, cluster string) (*kubebindv1alpha2.APIServiceExportTemplateList, error) {
451640
templates, err := h.kubeManager.ListTemplates(ctx, cluster)

0 commit comments

Comments
 (0)