diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1d725e9c7..8a0c828ee 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,19 +27,38 @@ jobs: name: go-test-e2e runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: go-version: v1.24.0 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: cd web && npm install + - run: make build-web - run: make test-e2e + + go-test-e2e-contrib: + name: go-test-e2e-contrib + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: v1.24.0 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - run: cd web && npm install + - run: make build-web - run: make test-e2e-contribs go-test: name: go-test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: go-version: v1.24.0 - run: make test @@ -48,8 +67,8 @@ jobs: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: go-version: v1.24.0 - run: make lint @@ -58,8 +77,8 @@ jobs: name: verify runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: go-version: v1.24.0 - run: make verify diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index c7cc732f1..c24aaf75f 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-go@v5 diff --git a/.github/workflows/image.yaml b/.github/workflows/image.yaml index 2d18b9830..4cd2d4faf 100644 --- a/.github/workflows/image.yaml +++ b/.github/workflows/image.yaml @@ -16,18 +16,23 @@ jobs: image: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: go-version: v1.24.0 check-latest: true + # We need this to remove local tags that are not semver so goreleaser doesn't get confused. - name: Delete non-semver tags run: 'git tag -d $(git tag -l | grep -v "^v")' + + # Set up Docker Buildx for multi-platform builds + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + # If you notice signing errors, you may need to update the cosign version. - uses: sigstore/cosign-installer@v3.7.0 - - name: Install ko - run: go install github.com/google/ko@latest + - name: Install Helm uses: azure/setup-helm@v3 @@ -37,16 +42,45 @@ jobs: - name: Set LDFLAGS run: echo LDFLAGS="$(make ldflags)" | tee -a >> $GITHUB_ENV - # Build ko from HEAD, build and push an image tagged with the commit SHA, - # then keylessly sign it with cosign. - - name: Publish and sign konnector image + # Login to GitHub Container Registry (used by both ko and Docker) + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Build and push konnector image using Dockerfile.konnector + - name: Build and push konnector image + uses: docker/build-push-action@v6 + id: build-konnector + with: + context: . + file: ./Dockerfile.konnector + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/konnector:latest + ghcr.io/${{ github.repository_owner }}/konnector:${{ github.sha }} + ghcr.io/${{ github.repository_owner }}/konnector:${{ github.ref_name }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + LDFLAGS=${{ env.LDFLAGS }} + labels: | + org.opencontainers.image.title=Kube Bind Konnector + org.opencontainers.image.description=Kube Bind konnector component + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ github.ref_name }} + + # Sign the konnector image + - name: Sign konnector image env: - KO_DOCKER_REPO: ghcr.io/${{ github.repository_owner }}/konnector COSIGN_EXPERIMENTAL: 'true' run: | - echo "${{ github.token }}" | ko login ghcr.io --username "${{ github.actor }}" --password-stdin - img=$(ko build --bare --platform=all -t latest -t ${{ github.sha }} -t ${{github.ref_name}} ./cmd/konnector) - echo "built ${img}" + img="ghcr.io/${{ github.repository_owner }}/konnector@${{ steps.build-konnector.outputs.digest }}" + echo "signing ${img}" cosign sign ${img} \ --yes \ -a sha=${{ github.sha }} \ @@ -54,14 +88,37 @@ jobs: -a run_id=${{ github.run_id }} \ -a run_attempt=${{ github.run_attempt }} - - name: Publish and sign backend image + # Build and push backend image using Dockerfile (includes frontend) + - name: Build and push backend image + uses: docker/build-push-action@v6 + id: build + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/${{ github.repository_owner }}/backend:latest + ghcr.io/${{ github.repository_owner }}/backend:${{ github.sha }} + ghcr.io/${{ github.repository_owner }}/backend:${{ github.ref_name }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + LDFLAGS=${{ env.LDFLAGS }} + labels: | + org.opencontainers.image.title=Kube Bind Backend + org.opencontainers.image.description=Kube Bind backend with integrated Vue.js frontend + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ github.ref_name }} + + # Sign the backend image + - name: Sign backend image env: - KO_DOCKER_REPO: ghcr.io/${{ github.repository_owner }}/backend COSIGN_EXPERIMENTAL: 'true' run: | - echo "${{ github.token }}" | ko login ghcr.io --username "${{ github.actor }}" --password-stdin - img=$(ko build --bare --platform=all -t latest -t ${{ github.sha }} -t ${{github.ref_name}} ./cmd/backend) - echo "built ${img}" + img="ghcr.io/${{ github.repository_owner }}/backend@${{ steps.build.outputs.digest }}" + echo "signing ${img}" cosign sign ${img} \ --yes \ -a sha=${{ github.sha }} \ diff --git a/.gitignore b/.gitignore index cde76d088..8cf0e726b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,9 @@ coverage.* /bin docs/generators/cli-doc/cli-doc apiserviceexport.yaml -*.prod \ No newline at end of file +*.prod + +# Frontend dependencies and build +web/node_modules/ +web/.vite/ +web/*.tsbuildinfo \ No newline at end of file diff --git a/.golangci.yaml b/.golangci.yaml index f04f3412b..5ed496cd8 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -15,7 +15,6 @@ linters: - errcheck - errchkjson - gocritic - - godot - goprintffuncname - gosec - govet diff --git a/Dockerfile b/Dockerfile index 7448e03ed..8d063e063 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,4 +12,67 @@ # See the License for the specific language governing permissions and # limitations under the License. -FROM golang:1.24.0 AS builder +# Use node:lts-alpine for better compatibility and smaller size +FROM node:20.18.0-alpine3.20 AS ui-build-env +WORKDIR /app + +# Install build dependencies needed for native modules +RUN apk add --no-cache python3 make g++ + +# Copy package files +COPY ./web/package*.json ./ +COPY ./web/.npmrc ./ + +RUN npm install + +# Install dependencies with specific flags to handle optional deps and architecture issues +RUN npm ci --prefer-offline --no-audit --no-fund --no-optional + +# Copy the Vue app files +COPY ./web . + +# Set environment to avoid native dependency issues +ENV NODE_ENV=production +ENV VITE_BUILD_TARGET=docker + +# Building UI with Docker-specific config +RUN npm run build + +# Build Go binary with embedded UI assets +FROM golang:1.24.0 AS go-build-env +WORKDIR /app + +# Accept build arguments for multi-arch support +ARG TARGETARCH +ARG TARGETOS +ARG LDFLAGS + +RUN apt-get update && apt-get install -y make jq + +# Copy go.mod and go.sum files first for better caching +COPY go.mod . +COPY go.sum . + +# Copy the source code +COPY . . + +# Copy built UI assets for embedding +COPY --from=ui-build-env /app/dist ./backend/static/web/dist + +# Build with embedded assets +RUN if [ -n "$LDFLAGS" ]; then \ + echo "Building with LDFLAGS: $LDFLAGS for $TARGETOS/$TARGETARCH"; \ + CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="$LDFLAGS" -o bin/backend ./cmd/backend; \ + else \ + CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH make build; \ + fi + +FROM alpine:3.22.1 +RUN apk --update add ca-certificates + +COPY --from=go-build-env /app/bin/backend /bin +COPY --from=ui-build-env /app/dist /www + + + +ENTRYPOINT ["/bin/backend"] \ No newline at end of file diff --git a/Dockerfile.konnector b/Dockerfile.konnector new file mode 100644 index 000000000..121ea0bc3 --- /dev/null +++ b/Dockerfile.konnector @@ -0,0 +1,35 @@ +# Copyright 2025 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. + +FROM golang:1.24.0 AS builder +WORKDIR /app + +# Accept build arguments for multi-arch support +ARG TARGETARCH +ARG TARGETOS +ARG LDFLAGS + +# Copy the source code (needed for local replacements in go.mod) +COPY . . +RUN go mod download + +# Build the konnector binary +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="$LDFLAGS" -o bin/konnector ./cmd/konnector + +FROM alpine:3.22.1 +RUN apk --update add ca-certificates + +COPY --from=builder /app/bin/konnector /bin/konnector + +ENTRYPOINT ["/bin/konnector"] \ No newline at end of file diff --git a/Makefile b/Makefile index 805aaf32e..4a55a33e0 100644 --- a/Makefile +++ b/Makefile @@ -277,6 +277,26 @@ $(KCP): run-kcp: $(KCP) $(KCP_CMD) start --bind-address=127.0.0.1 +.PHONY: run-kcp-infra +run-kcp-infra: $(KCP) $(DEX) ## Run KCP infrastructure for e2e tests (blocking) + mkdir -p .kcp + $(MAKE) run-dex 2>&1 & DEX_PID=$$!; \ + $(MAKE) run-kcp &>.kcp/kcp.log & KCP_PID=$$!; \ + trap 'kill -TERM $$DEX_PID $$KCP_PID; rm -rf .kcp' TERM INT EXIT && \ + echo "Waiting for kcp to be ready (check .kcp/kcp.log)." && while ! KUBECONFIG=.kcp/admin.kubeconfig kubectl get --raw /readyz &>/dev/null; do sleep 1; echo -n "."; done && echo && \ + echo "KCP is ready. Press Ctrl+C to stop." && \ + wait $$KCP_PID + +.PHONY: test-e2e-only +ifdef USE_GOTESTSUM +test-e2e-only: $(GOTESTSUM) +endif +test-e2e-only: TEST_ARGS ?= +test-e2e-only: WORK_DIR ?= . +test-e2e-only: WHAT ?= ./test/e2e... +test-e2e-only: build ## Run e2e tests against existing KCP infrastructure + KUBECONFIG=$$PWD/.kcp/admin.kubeconfig GOOS=$(OS) GOARCH=$(ARCH) $(GO_TEST) -race -v -count $(COUNT) $(E2E_PARALLELISM_FLAG) $(WHAT) $(TEST_ARGS) + .PHONY: test-e2e ifdef USE_GOTESTSUM test-e2e: $(GOTESTSUM) @@ -298,7 +318,11 @@ test-e2e-contribs: $(CONTRIBS_E2E) ## Run e2e tests for external integrations test-e2e-contrib-kcp: $(DEX) $(KCP) $(CONTRIBS_E2E): - cd contrib/$(patsubst test-e2e-contrib-%,%,$@) && $(GO_TEST) -race -count $(COUNT) $(E2E_PARALLELISM_FLAG) ./test/e2e/... + mkdir .kcp + $(MAKE) run-kcp &>.kcp/kcp.log & KCP_PID=$$!; \ + trap 'kill -TERM $$KCP_PID; rm -rf .kcp' TERM INT EXIT && \ + echo "Waiting for kcp to be ready (check .kcp/kcp.log)." && while ! KUBECONFIG=.kcp/admin.kubeconfig kubectl get --raw /readyz &>/dev/null; do sleep 1; echo -n "."; done && echo && \ + cd contrib/$(patsubst test-e2e-contrib-%,%,$@) && KUBECONFIG=$$PWD/../../.kcp/admin.kubeconfig $(GO_TEST) -race -count $(COUNT) $(E2E_PARALLELISM_FLAG) ./test/e2e/... .PHONY: test ifdef USE_GOTESTSUM @@ -375,35 +399,53 @@ deploy-docs: venv ## Deploy docs . $(VENV)/activate; \ REMOTE=$(REMOTE) BRANCH=$(BRANCH) docs/scripts/deploy-docs.sh -# Example: make IMAGE_REPO=ghcr.io/ image-local +.PHONY: build-web +build-web: + cd web && npm run build + +# Example: make IMAGE_REPO=ghcr.io/ image-local +# Set PLATFORMS to override default architectures (e.g., make PLATFORMS=linux/amd64,linux/arm64 image-local) +# For local builds, default to current architecture on Linux platform to support --load +PLATFORMS ?= linux/$(ARCH) .PHONY: image-local image-local: - @echo "Building images locally with tag $(REV)" - @command -v ko >/dev/null 2>&1 || { echo "ko not found. Install with: go install github.com/google/ko@latest"; exit 1; } - - @echo "Building konnector image locally..." - KO_DOCKER_REPO=$(IMAGE_REPO) ko build \ - --local \ - -B \ - -t $(REV) \ - ./cmd/konnector - - @echo "Building backend image locally..." - KO_DOCKER_REPO=$(IMAGE_REPO) ko build \ - --local \ - -B \ - -t $(REV) \ - ./cmd/backend - - @echo "Successfully built local images:" - @echo " $(IMAGE_REPO)/konnector:$(REV)" - @echo " $(IMAGE_REPO)/backend:$(REV)" + @echo "Building multi-arch images locally with tag $(REV) for platforms: $(PLATFORMS)" + @command -v docker >/dev/null 2>&1 || { echo "docker not found. Please install Docker"; exit 1; } + @docker buildx version >/dev/null 2>&1 || { echo "docker buildx not found. Please enable buildx in Docker"; exit 1; } + + @# Create buildx builder if it doesn't exist + @docker buildx create --name kube-bind-builder --use 2>/dev/null || docker buildx use kube-bind-builder 2>/dev/null || true + @docker buildx inspect --bootstrap >/dev/null 2>&1 + + @echo "Building konnector multi-arch image locally..." + docker buildx build \ + --platform $(PLATFORMS) \ + --build-arg LDFLAGS="$(LDFLAGS)" \ + -t $(IMAGE_REPO)/konnector:$(REV) \ + -f Dockerfile.konnector \ + --load . + + @echo "Building backend multi-arch image locally..." + docker buildx build \ + --platform $(PLATFORMS) \ + --build-arg LDFLAGS="$(LDFLAGS)" \ + -t $(IMAGE_REPO)/backend:$(REV) \ + -f Dockerfile \ + --load . + + @echo "Successfully built multi-arch local images:" + @echo " $(IMAGE_REPO)/konnector:$(REV) ($(PLATFORMS))" + @echo " $(IMAGE_REPO)/backend:$(REV) ($(PLATFORMS))" + +# Kind cluster configuration +KIND_CLUSTER ?= kube-bind +DOCKER_REPO ?= $(IMAGE_REPO) .PHONY: kind-load kind-load: @echo "Loading images into kind cluster '$(KIND_CLUSTER)'" - kind load docker-image $(KO_DOCKER_REPO)/konnector:$(REV) --name $(KIND_CLUSTER) - kind load docker-image $(KO_DOCKER_REPO)/backend:$(REV) --name $(KIND_CLUSTER) + kind load docker-image $(DOCKER_REPO)/konnector:$(REV) --name $(KIND_CLUSTER) + kind load docker-image $(DOCKER_REPO)/backend:$(REV) --name $(KIND_CLUSTER) @echo "Successfully loaded images into kind cluster '$(KIND_CLUSTER)'" .PHONY: helm-build-local diff --git a/backend/auth/handler.go b/backend/auth/handler.go new file mode 100644 index 000000000..0757d2c8b --- /dev/null +++ b/backend/auth/handler.go @@ -0,0 +1,278 @@ +/* +Copyright 2025 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 auth + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gorilla/securecookie" + "golang.org/x/oauth2" + "k8s.io/klog/v2" + + "github.com/kube-bind/kube-bind/backend/client" + "github.com/kube-bind/kube-bind/backend/session" +) + +type AuthHandler struct { + oidc *OIDCServiceProvider + jwtService *JWTService + cookieSigningKey []byte + cookieEncryptionKey []byte +} + +func NewAuthHandler(oidc *OIDCServiceProvider, jwtService *JWTService, cookieSigningKey, cookieEncryptionKey []byte) *AuthHandler { + return &AuthHandler{ + oidc: oidc, + jwtService: jwtService, + cookieSigningKey: cookieSigningKey, + cookieEncryptionKey: cookieEncryptionKey, + } +} + +func (ah *AuthHandler) HandleAuthorize(w http.ResponseWriter, r *http.Request) { + logger := klog.FromContext(r.Context()).WithValues("method", r.Method, "url", r.URL.String()) + + params := client.GetQueryParams(r) + + var authReq AuthorizeRequest + if r.Method == http.MethodPost { + if err := json.NewDecoder(r.Body).Decode(&authReq); err != nil { + http.Error(w, "invalid JSON request", http.StatusBadRequest) + return + } + } else { + authReq = AuthorizeRequest{ + RedirectURL: params.RedirectURL, + ClientSideRedirectURL: params.ClientSideRedirectURL, + SessionID: params.SessionID, + ClusterID: params.ClusterID, + ClientType: ClientType(params.ClientType), + } + } + + if authReq.RedirectURL == "" || authReq.SessionID == "" { + logger.Error(errors.New("missing required parameters"), "failed to authorize") + ah.respondWithError(w, authReq.ClientType, "missing redirect_url or session_id", http.StatusBadRequest) + return + } + + scopes := []string{"openid", "profile", "email", "offline_access"} + dataCode, err := json.Marshal(authReq) + if err != nil { + logger.Info("failed to marshal auth code", "error", err) + ah.respondWithError(w, authReq.ClientType, err.Error(), http.StatusInternalServerError) + return + } + + encoded := base64.URLEncoding.EncodeToString(dataCode) + authURL := ah.oidc.OIDCProviderConfig(scopes).AuthCodeURL(encoded) + + http.Redirect(w, r, authURL, http.StatusFound) +} + +func (ah *AuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) { + logger := klog.FromContext(r.Context()).WithValues("method", r.Method, "url", r.URL.String()) + if errMsg := r.Form.Get("error"); errMsg != "" { + logger.Error(errors.New(errMsg), "failed to authorize") + http.Error(w, errMsg+": "+r.Form.Get("error_description"), http.StatusBadRequest) + return + } + + code := r.Form.Get("code") + if code == "" { + code = r.URL.Query().Get("code") + } + if code == "" { + logger.Error(errors.New("missing code"), "no code in request") + http.Error(w, fmt.Sprintf("no code in request: %q", r.Form), http.StatusBadRequest) + return + } + + state := r.Form.Get("state") + if state == "" { + state = r.URL.Query().Get("state") + } + + // URL decode the state parameter first (in case it's URL encoded) + if decodedState, err := url.QueryUnescape(state); err == nil { + state = decodedState + } + + decoded, err := base64.URLEncoding.DecodeString(state) + if err != nil { + logger.Error(err, "failed to decode state") + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + authCode := &AuthorizeRequest{} + if err := json.Unmarshal(decoded, authCode); err != nil { + logger.Error(err, "failed to unmarshal authCode") + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + token, err := ah.oidc.OIDCProviderConfig(nil).Exchange(r.Context(), code) + if err != nil { + logger.Error(err, "failed to exchange token") + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + sessionState, err := ah.createSessionState(authCode, token) + if err != nil { + logger.Error(err, "failed to create session sessionState") + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // Detect client type from redirect URL or user agent + clientType := authCode.ClientType + + if clientType == ClientTypeCLI { + // Generate JWT token for CLI + jwtToken, err := ah.jwtService.GenerateToken( + sessionState.Token.Subject, + sessionState.Token.Issuer, + sessionState.SessionID, + sessionState.ClusterID, + sessionState.RedirectURL, + 24*time.Hour, // 24 hours expiration + ) + if err != nil { + logger.Error(err, "failed to generate JWT token") + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // Redirect to CLI redirect with JWT token + parsedRedirectURL, err := url.Parse(authCode.RedirectURL) + if err != nil { + logger.Error(err, "failed to parse redirect URL") + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + values := parsedRedirectURL.Query() + values.Add("access_token", jwtToken) + values.Add("token_type", "Bearer") + values.Add("expires_in", "86400") // 24 hours + if authCode.ClusterID != "" { + values.Add("cluster_id", authCode.ClusterID) + } + parsedRedirectURL.RawQuery = values.Encode() + + http.Redirect(w, r, parsedRedirectURL.String(), http.StatusFound) + return + } + + // UI flow - set cookie and redirect to UI + cookieName := ah.generateCookieName(authCode.ClusterID) + s := securecookie.New(ah.cookieSigningKey, ah.cookieEncryptionKey) + encoded, err := s.Encode(cookieName, sessionState) + if err != nil { + logger.Error(err, "failed to encode secure session cookie") + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + secure := false + http.SetCookie(w, session.MakeCookie(r, cookieName, encoded, secure, 1*time.Hour)) + + clientParams := &client.ClientParameters{ + ClusterID: authCode.ClusterID, + ClientSideRedirectURL: authCode.ClientSideRedirectURL, + RedirectURL: authCode.RedirectURL, + SessionID: authCode.SessionID, + } + url := clientParams.WithParams(authCode.RedirectURL) + + http.Redirect(w, r, url, http.StatusFound) +} + +func (ah *AuthHandler) respondWithError(w http.ResponseWriter, clientType ClientType, message string, statusCode int) { + if clientType != ClientTypeCLI { + http.Error(w, message, statusCode) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + err := json.NewEncoder(w).Encode(AuthResponse{ + Success: false, + Error: message, + }) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + } +} + +func (ah *AuthHandler) generateCookieName(clusterID string) string { + if clusterID == "" { + return "kube-bind" + } + return fmt.Sprintf("kube-bind-%s", clusterID) +} + +func (ah *AuthHandler) createSessionState(authCode *AuthorizeRequest, token *oauth2.Token) (*session.State, error) { + jwtStr, ok := token.Extra("id_token").(string) + if !ok { + return nil, errors.New("no id_token value found in token") + } + + jwt, err := ah.unwrapJWT(jwtStr) + if err != nil { + return nil, fmt.Errorf("failed to unpack ID token: %w", err) + } + + var idToken struct { + Subject string `json:"sub"` + Issuer string `json:"iss"` + } + if err := json.Unmarshal(jwt, &idToken); err != nil { + return nil, fmt.Errorf("failed to parse ID token: %w", err) + } + + return &session.State{ + Token: session.TokenInfo{ + Subject: idToken.Subject, + Issuer: idToken.Issuer, + }, + SessionID: authCode.SessionID, + ClusterID: authCode.ClusterID, + RedirectURL: authCode.RedirectURL, + }, nil +} + +func (ah *AuthHandler) unwrapJWT(p string) ([]byte, error) { + parts := strings.Split(p, ".") + if len(parts) < 2 { + return nil, fmt.Errorf("OIDC: malformed JWT, expected 3 parts, got %d", len(parts)) + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("OIDC: malformed JWT payload: %w", err) + } + return payload, nil +} diff --git a/backend/auth/jwt.go b/backend/auth/jwt.go new file mode 100644 index 000000000..0dab65183 --- /dev/null +++ b/backend/auth/jwt.go @@ -0,0 +1,98 @@ +/* +Copyright 2025 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 auth + +import ( + "crypto/rand" + "crypto/rsa" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type JWTService struct { + privateKey *rsa.PrivateKey + publicKey *rsa.PublicKey + issuer string +} + +type Claims struct { + Subject string `json:"sub"` + Issuer string `json:"iss"` + SessionID string `json:"sid"` + ClusterID string `json:"cid"` + RedirectURL string `json:"red,omitempty"` + jwt.RegisteredClaims +} + +func NewJWTService(issuer string) (*JWTService, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, fmt.Errorf("failed to generate RSA private key: %w", err) + } + + return &JWTService{ + privateKey: privateKey, + publicKey: &privateKey.PublicKey, + issuer: issuer, + }, nil +} + +func (js *JWTService) GenerateToken(subject, oidcIssuer, sessionID, clusterID, redirectURL string, expiration time.Duration) (string, error) { + now := time.Now() + claims := &Claims{ + Subject: subject, + Issuer: oidcIssuer, + SessionID: sessionID, + ClusterID: clusterID, + RedirectURL: redirectURL, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: subject, + Issuer: js.issuer, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(expiration)), + NotBefore: jwt.NewNumericDate(now), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + return token.SignedString(js.privateKey) +} + +func (js *JWTService) ValidateToken(tokenString string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return js.publicKey, nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) + } + + if claims, ok := token.Claims.(*Claims); ok && token.Valid { + return claims, nil + } + + return nil, fmt.Errorf("invalid token") +} + +func (js *JWTService) GetPublicKey() *rsa.PublicKey { + return js.publicKey +} diff --git a/backend/auth/middleware.go b/backend/auth/middleware.go new file mode 100644 index 000000000..d377d8d11 --- /dev/null +++ b/backend/auth/middleware.go @@ -0,0 +1,145 @@ +/* +Copyright 2025 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 auth + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/gorilla/securecookie" + "k8s.io/klog/v2" + + "github.com/kube-bind/kube-bind/backend/session" +) + +type contextKey string + +const ( + AuthContextKey contextKey = "auth_context" +) + +type ClientType string + +const ( + ClientTypeUI ClientType = "ui" + ClientTypeCLI ClientType = "cli" +) + +type AuthContext struct { + SessionState *session.State + ClientType ClientType + IsValid bool +} + +type AuthMiddleware struct { + jwtService *JWTService + cookieSigningKey []byte + cookieEncryptionKey []byte +} + +func NewAuthMiddleware(jwtService *JWTService, cookieSigningKey, cookieEncryptionKey []byte) *AuthMiddleware { + return &AuthMiddleware{ + jwtService: jwtService, + cookieSigningKey: cookieSigningKey, + cookieEncryptionKey: cookieEncryptionKey, + } +} + +func (am *AuthMiddleware) AuthenticateRequest(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + logger := klog.FromContext(r.Context()) + + authCtx := &AuthContext{ + IsValid: false, + } + + authHeader := r.Header.Get("Authorization") + + if authHeader != "" { + if strings.HasPrefix(authHeader, "Bearer ") { + token := strings.TrimPrefix(authHeader, "Bearer ") + if claims, err := am.jwtService.ValidateToken(token); err == nil { + authCtx.SessionState = &session.State{ + Token: session.TokenInfo{ + Subject: claims.Subject, + Issuer: claims.Issuer, + }, + SessionID: claims.SessionID, + ClusterID: claims.ClusterID, + RedirectURL: claims.RedirectURL, + } + authCtx.IsValid = true + authCtx.ClientType = ClientTypeCLI + } else { + logger.V(2).Info("Invalid JWT token", "error", err) + } + } + } + + // Fall back to cookie authentication (for UI clients) + if !authCtx.IsValid { + cookieName := "kube-bind" + if r.URL.Query().Get("cluster_id") != "" { + cookieName = "kube-bind-" + r.URL.Query().Get("cluster_id") + } + if cookie, err := r.Cookie(cookieName); err == nil { + s := securecookie.New(am.cookieSigningKey, am.cookieEncryptionKey) + state := &session.State{} + if err := s.Decode(cookieName, cookie.Value, state); err == nil { + authCtx.SessionState = state + authCtx.IsValid = true + authCtx.ClientType = ClientTypeUI + } else { + logger.V(2).Info("Failed to decode session cookie", "error", err) + } + } + } + + // Add auth context to request context + ctx := context.WithValue(r.Context(), AuthContextKey, authCtx) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func GetAuthContext(ctx context.Context) *AuthContext { + if authCtx, ok := ctx.Value(AuthContextKey).(*AuthContext); ok { + return authCtx + } + return &AuthContext{ClientType: ClientTypeUI, IsValid: false} +} + +func RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authCtx := GetAuthContext(r.Context()) + if !authCtx.IsValid { + if authCtx.ClientType == ClientTypeCLI { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + err := json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized", "message": "Valid JWT token required"}) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + } + } else { + http.Error(w, "unauthorized", http.StatusUnauthorized) + } + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/backend/auth/types.go b/backend/auth/types.go new file mode 100644 index 000000000..0f95b2558 --- /dev/null +++ b/backend/auth/types.go @@ -0,0 +1,98 @@ +/* +Copyright 2025 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 auth + +import ( + "context" + "time" + + oidc "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +type AuthResponse struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` + Message string `json:"message,omitempty"` + Meta map[string]interface{} `json:"meta,omitempty"` +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + ExpiresAt time.Time `json:"expires_at"` + Scope string `json:"scope,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + + ClusterID string `json:"cluster_id,omitempty"` +} + +// TODO: We should remove client_side_redirect_url. +// https://github.com/kube-bind/kube-bind/issues/362 + +type AuthorizeRequest struct { + RedirectURL string `json:"redirect_url" form:"redirect_url"` + ClientSideRedirectURL string `json:"client_side_redirect_url" form:"client_side_redirect_url"` + SessionID string `json:"session_id" form:"session_id"` + ClusterID string `json:"cluster_id" form:"cluster_id"` + ClientType ClientType `json:"client_type" form:"client_type"` +} + +type CallbackRequest struct { + Code string `json:"code" form:"code"` + State string `json:"state" form:"state"` + Error string `json:"error,omitempty" form:"error"` + ErrorDescription string `json:"error_description,omitempty" form:"error_description"` +} + +type OIDCServiceProvider struct { + clientID string + clientSecret string + redirectURI string + issuerURL string + + verifier *oidc.IDTokenVerifier + provider *oidc.Provider +} + +func NewOIDCServiceProvider(ctx context.Context, clientID, clientSecret, redirectURI, issuerURL string) (*OIDCServiceProvider, error) { + provider, err := oidc.NewProvider(ctx, issuerURL) + if err != nil { + return nil, err + } + + return &OIDCServiceProvider{ + clientID: clientID, + clientSecret: clientSecret, + redirectURI: redirectURI, + issuerURL: issuerURL, + provider: provider, + verifier: provider.Verifier(&oidc.Config{ClientID: clientID}), + }, nil +} + +func (o *OIDCServiceProvider) OIDCProviderConfig(scopes []string) *oauth2.Config { + return &oauth2.Config{ + ClientID: o.clientID, + ClientSecret: o.clientSecret, + Endpoint: o.provider.Endpoint(), + RedirectURL: o.redirectURI, + Scopes: scopes, + } +} diff --git a/backend/client/client.go b/backend/client/client.go new file mode 100644 index 000000000..adfee70fb --- /dev/null +++ b/backend/client/client.go @@ -0,0 +1,74 @@ +/* +Copyright 2025 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 client + +import ( + "net/http" + "net/url" +) + +type ClientParameters struct { + ClusterID string + RedirectURL string + // This is clients side redirect, in example used for CLI via UI. + ClientSideRedirectURL string + SessionID string + ClientType string + + IsClusterScoped bool +} + +// GetQueryParams extracts the client parameters from the given HTTP request. +func GetQueryParams(r *http.Request) *ClientParameters { + p := &ClientParameters{ + ClusterID: r.URL.Query().Get("cluster_id"), + RedirectURL: r.URL.Query().Get("redirect_url"), + ClientSideRedirectURL: r.URL.Query().Get("client_side_redirect_url"), + SessionID: r.URL.Query().Get("session_id"), + ClientType: r.URL.Query().Get("client_type"), + } + p.IsClusterScoped = p.ClusterID != "" + return p +} + +// WithParams adds the client parameters to the given URL as query parameters. +func (r *ClientParameters) WithParams(urlStr string) string { + parsedURL, err := url.Parse(urlStr) + if err != nil { + // Return original URL if parsing fails + return urlStr + } + + query := parsedURL.Query() + + // Add all non-empty client parameters + if r.ClusterID != "" { + query.Set("cluster_id", r.ClusterID) + } + if r.ClientSideRedirectURL != "" { + query.Set("redirect_url", r.ClientSideRedirectURL) + } + if r.SessionID != "" { + query.Set("session_id", r.SessionID) + } + if r.ClientType != "" { + query.Set("client_type", r.ClientType) + } + + parsedURL.RawQuery = query.Encode() + return parsedURL.String() +} diff --git a/backend/http/handler.go b/backend/http/handler.go index 5fbd16fbf..611239f69 100644 --- a/backend/http/handler.go +++ b/backend/http/handler.go @@ -17,35 +17,27 @@ limitations under the License. package http import ( - "bytes" "context" - "encoding/base64" "encoding/json" "errors" "fmt" - htmltemplate "html/template" "net/http" - "net/url" "strings" "time" "github.com/gorilla/mux" - "github.com/gorilla/securecookie" - "golang.org/x/oauth2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" 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" - "github.com/kube-bind/kube-bind/backend/session" - "github.com/kube-bind/kube-bind/backend/template" + "github.com/kube-bind/kube-bind/backend/spaserver" bindversion "github.com/kube-bind/kube-bind/pkg/version" kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" -) - -var ( - resourcesTemplate = htmltemplate.Must(htmltemplate.New("resource").Parse(mustRead(template.Files.ReadFile, "resources.gohtml"))) + "github.com/kube-bind/kube-bind/web" ) // See https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching?hl=en @@ -56,7 +48,9 @@ var noCacheHeaders = map[string]string{ } type handler struct { - oidc *OIDCServiceProvider + oidc *auth.OIDCServiceProvider + authHandler *auth.AuthHandler + authMiddleware *auth.AuthMiddleware scope kubebindv1alpha2.InformerScope oidcAuthorizeURL string @@ -70,23 +64,41 @@ type handler struct { client *http.Client kubeManager *kubernetes.Manager + + frontend string } func NewHandler( - provider *OIDCServiceProvider, + provider *auth.OIDCServiceProvider, oidcAuthorizeURL, backendCallbackURL, providerPrettyName, testingAutoSelect string, cookieSigningKey, cookieEncryptionKey []byte, schemaSource string, scope kubebindv1alpha2.InformerScope, mgr *kubernetes.Manager, + frontend string, ) (*handler, error) { + // Create JWT service for CLI authentication + jwtService, err := auth.NewJWTService("kube-bind-backend") + if err != nil { + return nil, fmt.Errorf("failed to create JWT service: %w", err) + } + + // Create auth handler for generic authentication flows + authHandler := auth.NewAuthHandler(provider, jwtService, cookieSigningKey, cookieEncryptionKey) + + // Create auth middleware for request authentication + authMiddleware := auth.NewAuthMiddleware(jwtService, cookieSigningKey, cookieEncryptionKey) + return &handler{ oidc: provider, + authHandler: authHandler, + authMiddleware: authMiddleware, oidcAuthorizeURL: oidcAuthorizeURL, backendCallbackURL: backendCallbackURL, providerPrettyName: providerPrettyName, testingAutoSelect: testingAutoSelect, schemaSource: schemaSource, + frontend: frontend, scope: scope, client: http.DefaultClient, kubeManager: mgr, @@ -95,43 +107,100 @@ func NewHandler( }, nil } -func (h *handler) AddRoutes(mux *mux.Router) { - // Server contains double routes for when backend is multi-cluster aware or single cluster. - // When called multi-cluster aware route in single cluster mode, it will ignore cluster parameter. - mux.HandleFunc("/clusters/{cluster}/exports", h.handleServiceExport).Methods("GET") - mux.HandleFunc("/exports", h.handleServiceExport).Methods("GET") - - mux.HandleFunc("/clusters/{cluster}/resources", h.handleResources).Methods("GET") - mux.HandleFunc("/resources", h.handleResources).Methods("GET") - - mux.HandleFunc("/clusters/{cluster}/bind", h.handleBind).Methods("GET") - mux.HandleFunc("/bind", h.handleBind).Methods("GET") +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) + + // Generic authentication routes (support both UI and CLI) + mux.HandleFunc("/api/authorize", h.authHandler.HandleAuthorize).Methods(http.MethodGet, http.MethodPost) + mux.HandleFunc("/api/callback", h.authHandler.HandleCallback).Methods(http.MethodGet) + mux.HandleFunc("/api/logout", h.handleLogout).Methods(http.MethodPost) + + mux.HandleFunc("/api/exports", h.handleServiceExport).Methods(http.MethodGet) + mux.HandleFunc("/exports", h.handleExportsRedirect).Methods(http.MethodGet) // This provides HTTP 302 redirects for backwards compatibility with older CLI versions and in general nicer UX. + + // Protected API routes (require authentication) + apiRouter := mux.PathPrefix("/api").Subrouter() + apiRouter.Use(h.authMiddleware.AuthenticateRequest) + + apiRouter.Handle("/templates", auth.RequireAuth(http.HandlerFunc(h.handleTemplates))).Methods(http.MethodGet) + 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) + + switch { + // Development mode: proxy to frontend dev server + case strings.HasPrefix(h.frontend, "http://"): + spaserver, err := spaserver.NewSPAReverseProxyServer(h.frontend) + if err != nil { + return err + } + mux.PathPrefix("/").Handler(spaserver) + default: + fs := web.GetFileSystem() + mux.PathPrefix("/").Handler(spaserver.NewSPAFileServer(fs)) + } + return nil +} - mux.HandleFunc("/clusters/{cluster}/authorize", h.handleAuthorize).Methods("GET") - mux.HandleFunc("/authorize", h.handleAuthorize).Methods("GET") +func (h *handler) handleHealthz(w http.ResponseWriter, r *http.Request) { + prepareNoCache(w) + w.WriteHeader(http.StatusOK) +} - mux.HandleFunc("/callback", h.handleCallback).Methods("GET") - mux.HandleFunc("/healthz", h.handleHealthz).Methods("GET") +func (h *handler) handlePing(w http.ResponseWriter, r *http.Request) { + prepareNoCache(w) + w.WriteHeader(http.StatusOK) + w.Write([]byte("pong")) //nolint:errcheck } -func (h *handler) handleHealthz(w http.ResponseWriter, r *http.Request) { +func (h *handler) handleLogout(w http.ResponseWriter, r *http.Request) { prepareNoCache(w) + + // Get cluster ID from query parameter if present + clusterID := r.URL.Query().Get("cluster_id") + + // Determine cookie name based on cluster ID + cookieName := "kube-bind" + if clusterID != "" { + cookieName = "kube-bind-" + clusterID + } + + // Create an expired cookie to clear the existing one + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Path: "/", + Domain: "", + Expires: time.Unix(0, 0), // Set to Unix epoch (Jan 1, 1970) to expire immediately + HttpOnly: true, + Secure: r.TLS != nil, // Set secure flag if HTTPS + SameSite: http.SameSiteLaxMode, + }) + w.WriteHeader(http.StatusOK) + w.Write([]byte("logged out")) //nolint:errcheck +} + +func (h *handler) handleExportsRedirect(w http.ResponseWriter, r *http.Request) { + logger := getLogger(r) + redirectURL := "/api/exports" + + // Preserve query parameters + if r.URL.RawQuery != "" { + redirectURL += "?" + r.URL.RawQuery + } + + logger.Info("redirecting CLI exports request", "from", r.URL.Path, "to", redirectURL) + http.Redirect(w, r, redirectURL, http.StatusFound) } func (h *handler) handleServiceExport(w http.ResponseWriter, r *http.Request) { logger := klog.FromContext(r.Context()).WithValues("method", r.Method, "url", r.URL.String()) - cluster := mux.Vars(r)["cluster"] - singleClusterScoped := cluster == "" + params := client.GetQueryParams(r) - oidcAuthorizeURL := h.oidcAuthorizeURL - if oidcAuthorizeURL == "" { - if singleClusterScoped { - oidcAuthorizeURL = fmt.Sprintf("http://%s/authorize", r.Host) - } else { - oidcAuthorizeURL = fmt.Sprintf("http://%s/clusters/%s/authorize", r.Host, cluster) - } - } + oidcAuthorizeURL := params.WithParams(fmt.Sprintf("http://%s/api/authorize", r.Host)) ver, err := bindversion.BinaryVersion(componentbaseversion.Get().GitVersion) if err != nil { @@ -170,284 +239,87 @@ func (h *handler) handleServiceExport(w http.ResponseWriter, r *http.Request) { w.Write(bs) //nolint:errcheck } -// prepareNoCache prepares headers for preventing browser caching. -func prepareNoCache(w http.ResponseWriter) { - // Set NoCache headers - for k, v := range noCacheHeaders { - w.Header().Set(k, v) - } -} - -func getLogger(r *http.Request) klog.Logger { - return klog.FromContext(r.Context()).WithValues("method", r.Method, "url", r.URL.String()) -} - -func generateCookieName(clusterID string) string { - if clusterID == "" { - return "kube-bind" - } - - return fmt.Sprintf("kube-bind-%s", clusterID) -} - -func (h *handler) handleAuthorize(w http.ResponseWriter, r *http.Request) { - logger := getLogger(r) - - providerCluster := mux.Vars(r)["cluster"] - - callbackPort := r.URL.Query().Get("p") - - scopes := []string{"openid", "profile", "email", "offline_access"} - code := &AuthCode{ - RedirectURL: r.URL.Query().Get("u"), - SessionID: r.URL.Query().Get("s"), - ClusterID: r.URL.Query().Get("c"), - ProviderClusterID: providerCluster, // used in multicluster-runtime providers - } - if callbackPort != "" && code.RedirectURL == "" { - code.RedirectURL = fmt.Sprintf("http://127.0.0.1:%s/callback", callbackPort) - } - - if code.RedirectURL == "" || code.SessionID == "" || code.ClusterID == "" { - logger.Error(errors.New("missing redirect url or session id or cluster id"), "failed to authorize") - http.Error(w, "missing redirect_url, session_id or cluster_id", http.StatusBadRequest) - return - } - - dataCode, err := json.Marshal(code) - if err != nil { - logger.Info("failed to marshal auth code", "error", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - encoded := base64.URLEncoding.EncodeToString(dataCode) - authURL := h.oidc.OIDCProviderConfig(scopes).AuthCodeURL(encoded) - http.Redirect(w, r, authURL, http.StatusFound) -} - -// handleCallback handle the authorization redirect callback from OAuth2 auth flow. -func (h *handler) handleCallback(w http.ResponseWriter, r *http.Request) { +func (h *handler) handleTemplates(w http.ResponseWriter, r *http.Request) { logger := getLogger(r) + prepareNoCache(w) + logger.Info("getting templates") - if errMsg := r.Form.Get("error"); errMsg != "" { - logger.Error(errors.New(errMsg), "failed to authorize") - http.Error(w, errMsg+": "+r.Form.Get("error_description"), http.StatusBadRequest) - return - } - - code := r.Form.Get("code") - if code == "" { - code = r.URL.Query().Get("code") - } - if code == "" { - logger.Error(errors.New("missing code"), "no code in request") - http.Error(w, fmt.Sprintf("no code in request: %q", r.Form), http.StatusBadRequest) - return - } - - state := r.Form.Get("state") - if state == "" { - state = r.URL.Query().Get("state") - } - decoded, err := base64.StdEncoding.DecodeString(state) - if err != nil { - logger.Error(err, "failed to decode state") - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - authCode := &AuthCode{} - if err := json.Unmarshal(decoded, authCode); err != nil { - logger.Error(err, "failed to unmarshal authCode") - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - // TODO: sign state and verify that it is not faked by the oauth provider + params := client.GetQueryParams(r) - token, err := h.oidc.OIDCProviderConfig(nil).Exchange(r.Context(), code) + templates, err := h.listTemplates(r.Context(), params.ClusterID) if err != nil { - logger.Error(err, "failed to exchange token") + logger.Error(err, "failed to get template resources") http.Error(w, "internal error", http.StatusInternalServerError) - return - } - cookieName := generateCookieName(authCode.ClusterID) - sessionState, err := createSessionState(authCode, token) - if err != nil { - logger.Error(err, "failed to create session sessionState") - http.Error(w, "internal error", http.StatusInternalServerError) return } - s := securecookie.New(h.cookieSigningKey, h.cookieEncryptionKey) - encoded, err := s.Encode(cookieName, sessionState) - if err != nil { - logger.Error(err, "failed to encode secure session cookie") + w.Header().Set("Content-Type", "application/json") + // For UI, return direct result as before + if err := json.NewEncoder(w).Encode(templates); err != nil { + logger.Error(err, "failed to encode JSON response") http.Error(w, "internal error", http.StatusInternalServerError) - return - } - - // setting to false so it works over http://localhost - secure := false - - http.SetCookie(w, session.MakeCookie(r, cookieName, encoded, secure, 1*time.Hour)) - if authCode.ProviderClusterID == "" { - http.Redirect(w, r, "/resources?s="+cookieName, http.StatusFound) - } else { - http.Redirect(w, r, "/clusters/"+authCode.ProviderClusterID+"/resources?s="+cookieName, http.StatusFound) - } -} - -func unwrapJWT(p string) ([]byte, error) { - parts := strings.Split(p, ".") - if len(parts) < 2 { - return nil, fmt.Errorf("OIDC: malformed JWT, expected 3 parts, got %d", len(parts)) } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return nil, fmt.Errorf("OIDC: malformed JWT payload: %w", err) - } - return payload, nil } -func createSessionState(authCode *AuthCode, token *oauth2.Token) (*session.State, error) { - jwtStr, ok := token.Extra("id_token").(string) - if !ok { - return nil, errors.New("no id_token value found in token") - } - - jwt, err := unwrapJWT(jwtStr) - if err != nil { - return nil, fmt.Errorf("failed to unpack ID token: %w", err) - } - - var idToken struct { - Subject string `json:"sub"` - Issuer string `json:"iss"` - } - if err := json.Unmarshal(jwt, &idToken); err != nil { - return nil, fmt.Errorf("failed to parse ID token: %w", err) - } - - return &session.State{ - Token: session.TokenInfo{ - Subject: idToken.Subject, - Issuer: idToken.Issuer, - }, - SessionID: authCode.SessionID, - ClusterID: authCode.ClusterID, - RedirectURL: authCode.RedirectURL, - }, nil -} - -type UISchema struct { - Scope string // "Namespaced" or "Cluster" - - Name string - Description string - - Resources []kubebindv1alpha2.APIServiceExportResource - PermissionClaims []kubebindv1alpha2.PermissionClaim - Namespaces []kubebindv1alpha2.Namespaces - - // SessionID - SessionID string -} - -func (h *handler) handleResources(w http.ResponseWriter, r *http.Request) { +func (h *handler) handleCollections(w http.ResponseWriter, r *http.Request) { logger := getLogger(r) - prepareNoCache(w) + logger.Info("getting collections") - providerCluster := mux.Vars(r)["cluster"] - sessionID := r.URL.Query().Get("s") - singleClusterScoped := providerCluster == "" + params := client.GetQueryParams(r) - if h.testingAutoSelect != "" { - parts := strings.SplitN(h.testingAutoSelect, ".", 2) - if singleClusterScoped { - http.Redirect(w, r, "/resources/"+parts[0]+"/"+parts[1], http.StatusFound) - } else { - http.Redirect(w, r, "/clusters/"+providerCluster+"/resources/"+parts[0]+"/"+parts[1], http.StatusFound) - } - return - } - - templates, err := h.listCollectionTemplates(r.Context(), providerCluster) + collections, err := h.listCollections(r.Context(), params.ClusterID) if err != nil { - logger.Error(err, "failed to get template resources") + logger.Error(err, "failed to get collection resources") http.Error(w, "internal error", http.StatusInternalServerError) return } - result := make([]UISchema, 0, len(templates.Items)) - for _, item := range templates.Items { - if !strings.EqualFold(h.scope.String(), string(item.Spec.Scope)) && h.scope != kubebindv1alpha2.ClusterScope { - continue - } - - result = append(result, UISchema{ - Name: item.GetName(), - Scope: string(item.Spec.Scope), - PermissionClaims: item.Spec.PermissionClaims, - Resources: item.Spec.Resources, - Namespaces: item.Spec.Namespaces, - SessionID: sessionID, - }) - } - - bs := bytes.Buffer{} - if err := resourcesTemplate.Execute(&bs, struct { - Cluster string - Schemas []UISchema - }{ - Cluster: providerCluster, - Schemas: result, - }); err != nil { - logger.Error(err, "failed to execute template") + w.Header().Set("Content-Type", "application/json") + // For UI, return direct result as before + if err := json.NewEncoder(w).Encode(collections); err != nil { + logger.Error(err, "failed to encode JSON response") http.Error(w, "internal error", http.StatusInternalServerError) - return } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Write(bs.Bytes()) //nolint:errcheck } func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) { logger := getLogger(r) - templateName := r.URL.Query().Get("template") - providerCluster := mux.Vars(r)["cluster"] + params := client.GetQueryParams(r) prepareNoCache(w) - cookieName := r.URL.Query().Get("s") - ck, err := r.Cookie(r.URL.Query().Get("s")) - if err != nil { - logger.Error(err, "failed to get session cookie") - http.Error(w, "no session cookie found", http.StatusBadRequest) - return - } + // Get auth context (already verified by middleware) + authCtx := auth.GetAuthContext(r.Context()) + state := authCtx.SessionState - state := session.State{} - s := securecookie.New(h.cookieSigningKey, h.cookieEncryptionKey) - if err := s.Decode(cookieName, ck.Value, &state); err != nil { - logger.Error(err, "failed to decode session cookie") + kfg, err := h.kubeManager.HandleResources(r.Context(), state.Token.Subject+"#"+state.ClusterID, params.ClusterID) + if err != nil { + logger.Error(err, "failed to handle resources") http.Error(w, "internal error", http.StatusInternalServerError) return } - kfg, err := h.kubeManager.HandleResources(r.Context(), state.Token.Subject+"#"+state.ClusterID, providerCluster) - if err != nil { - logger.Error(err, "failed to handle resources") - http.Error(w, "internal error", http.StatusInternalServerError) + // Parse JSON request body + const maxBodySize = 1 << 20 // 1 MB + r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) + var bindRequest kubebindv1alpha2.BindableResourcesRequest + if err := json.NewDecoder(r.Body).Decode(&bindRequest); err != nil { + logger.Error(err, "failed to parse JSON request body") + var maxBytesError *http.MaxBytesError + if errors.As(err, &maxBytesError) { + http.Error(w, "request body too large", http.StatusRequestEntityTooLarge) + } else { + http.Error(w, "invalid JSON request body", http.StatusBadRequest) + } return } // Module consist of many resources and permissionClaims. Read it and translate to - template, err := h.kubeManager.GetTemplates(r.Context(), providerCluster, templateName) + template, err := h.kubeManager.GetTemplates(r.Context(), params.ClusterID, bindRequest.TemplateRef.Name) if err != nil { - logger.Error(err, "failed to get template") + logger.Error(err, "failed to get template", "template", bindRequest.TemplateRef.Name, "cluster", params.ClusterID) http.Error(w, "internal error", http.StatusInternalServerError) return } @@ -458,7 +330,7 @@ func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) { Kind: "APIServiceExportRequest", }, ObjectMeta: kubebindv1alpha2.NameObjectMeta{ - Name: templateName, + Name: bindRequest.Name, }, Spec: kubebindv1alpha2.APIServiceExportRequestSpec{ Resources: template.Spec.Resources, @@ -475,7 +347,7 @@ func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) { return } - response := kubebindv1alpha2.BindingResponse{ + response := kubebindv1alpha2.BindingResourceResponse{ TypeMeta: metav1.TypeMeta{ APIVersion: kubebindv1alpha2.SchemeGroupVersion.String(), Kind: "BindingResponse", @@ -497,52 +369,53 @@ func (h *handler) handleBind(w http.ResponseWriter, r *http.Request) { return } - encoded := base64.URLEncoding.EncodeToString(payload) + w.Header().Set("Content-Type", "application/json") + w.Write(payload) //nolint:errcheck +} - parsedAuthURL, err := url.Parse(state.RedirectURL) +// listTemplates fetches the list of APIServiceExportTemplates from the backend cluster without checking +// 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) if err != nil { - logger.Error(err, "failed to parse redirect URL") - http.Error(w, "internal error", http.StatusInternalServerError) - return + return nil, fmt.Errorf("failed to list collections: %w", err) } - values := parsedAuthURL.Query() - values.Add("response", encoded) - - parsedAuthURL.RawQuery = values.Encode() - - logger.V(1).Info("redirecting to auth callback", "url", state.RedirectURL+"?response=") - http.Redirect(w, r, parsedAuthURL.String(), http.StatusFound) + return templates, nil } -func mustRead(f func(name string) ([]byte, error), name string) string { - bs, err := f(name) +// listCollections fetches the list of Collections from the backend cluster. +func (h *handler) listCollections(ctx context.Context, cluster string) (*kubebindv1alpha2.CollectionList, error) { + collections, err := h.kubeManager.ListCollections(ctx, cluster) if err != nil { - panic(err) + return nil, fmt.Errorf("failed to list collections: %w", err) } - return string(bs) + + return collections, nil } -// listCollectionTemplates fetches the list of Collections from the backend cluster. -// Flow is: -// 1. List Collection and check what modules we are targeting -// 2. Get templates from the backend cluster and construct shallow-bound schemas (no crd content). -func (h *handler) listCollectionTemplates(ctx context.Context, cluster string) (*kubebindv1alpha2.APIServiceExportTemplateList, error) { - collections, err := h.kubeManager.ListCollections(ctx, cluster) +func (h *handler) handleBindableResources(w http.ResponseWriter, r *http.Request) { + logger := getLogger(r) + + bs, err := json.Marshal(&kubebindv1alpha2.ClaimableAPIs) if err != nil { - return nil, fmt.Errorf("failed to list collections: %w", err) + logger.Error(err, "failed to marshal resources") + http.Error(w, "internal error", http.StatusInternalServerError) + return } - templates := &kubebindv1alpha2.APIServiceExportTemplateList{} - for _, collection := range collections.Items { - for _, t := range collection.Spec.Templates { - template, err := h.kubeManager.GetTemplates(ctx, cluster, t.Name) - if err != nil { - return nil, fmt.Errorf("failed to get template %q: %w", t.Name, err) - } - templates.Items = append(templates.Items, *template) - } + w.Header().Set("Content-Type", "application/json") + w.Write(bs) //nolint:errcheck +} + +// prepareNoCache prepares headers for preventing browser caching. +func prepareNoCache(w http.ResponseWriter) { + // Set NoCache headers + for k, v := range noCacheHeaders { + w.Header().Set(k, v) } +} - return templates, nil +func getLogger(r *http.Request) klog.Logger { + return klog.FromContext(r.Context()).WithValues("method", r.Method, "url", r.URL.String()) } diff --git a/backend/http/oidc.go b/backend/http/oidc.go deleted file mode 100644 index 2fdb0b98a..000000000 --- a/backend/http/oidc.go +++ /dev/null @@ -1,69 +0,0 @@ -/* -Copyright 2022 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 http - -import ( - "context" - - oidc "github.com/coreos/go-oidc/v3/oidc" - "golang.org/x/oauth2" -) - -// AuthCode is sent and received by to/from the OIDC provider. It's the state -// we can use to map the OIDC provider's response to the request from the client. -type AuthCode struct { - RedirectURL string `json:"redirectURL"` - SessionID string `json:"sid"` - ClusterID string `json:"cid"` - ProviderClusterID string `json:"pcid"` -} - -type OIDCServiceProvider struct { - clientID string - clientSecret string - redirectURI string - issuerURL string - - verifier *oidc.IDTokenVerifier - provider *oidc.Provider -} - -func NewOIDCServiceProvider(ctx context.Context, clientID, clientSecret, redirectURI, issuerURL string) (*OIDCServiceProvider, error) { - provider, err := oidc.NewProvider(ctx, issuerURL) - if err != nil { - return nil, err - } - - return &OIDCServiceProvider{ - clientID: clientID, - clientSecret: clientSecret, - redirectURI: redirectURI, - issuerURL: issuerURL, - provider: provider, - verifier: provider.Verifier(&oidc.Config{ClientID: clientID}), - }, nil -} - -func (o *OIDCServiceProvider) OIDCProviderConfig(scopes []string) *oauth2.Config { - return &oauth2.Config{ - ClientID: o.clientID, - ClientSecret: o.clientSecret, - Endpoint: o.provider.Endpoint(), - RedirectURL: o.redirectURI, - Scopes: scopes, - } -} diff --git a/backend/kubernetes/manager.go b/backend/kubernetes/manager.go index a44980063..d7846e5fd 100644 --- a/backend/kubernetes/manager.go +++ b/backend/kubernetes/manager.go @@ -181,6 +181,22 @@ func (m *Manager) ListCollections(ctx context.Context, cluster string) (*kubebin return &collections, nil } +func (m *Manager) ListTemplates(ctx context.Context, cluster string) (*kubebindv1alpha2.APIServiceExportTemplateList, error) { + cl, err := m.manager.GetCluster(ctx, cluster) + if err != nil { + return nil, err + } + c := cl.GetClient() + + var templates kubebindv1alpha2.APIServiceExportTemplateList + err = c.List(ctx, &templates) + if err != nil { + return nil, err + } + + return &templates, nil +} + func (m *Manager) GetTemplates(ctx context.Context, cluster, name string) (*kubebindv1alpha2.APIServiceExportTemplate, error) { cl, err := m.manager.GetCluster(ctx, cluster) if err != nil { diff --git a/backend/options/options.go b/backend/options/options.go index 3a9f7c9df..86fb80999 100644 --- a/backend/options/options.go +++ b/backend/options/options.go @@ -61,6 +61,10 @@ type ExtraOptions struct { TestingAutoSelect string TestingSkipNameValidation bool + + // If ControllerFrontend starts with http:// it is treated as a URL to a SPA server + // Else - it is treated as a path to static files to be served. + Frontend string } type completedOptions struct { @@ -95,6 +99,7 @@ func NewOptions() *Options { ClusterScopedIsolation: string(kubebindv1alpha2.IsolationPrefixed), ServerURL: "", SchemaSource: CustomResourceDefinitionSource.String(), + Frontend: "/www", }, } } @@ -139,6 +144,7 @@ func (options *Options) AddFlags(fs *pflag.FlagSet) { fs.StringVar(&options.ExternalAddress, "external-address", options.ExternalAddress, "The external address for the service provider cluster, including https:// and port. If not specified, service account's hosts are used.") fs.StringVar(&options.ExternalCAFile, "external-ca-file", options.ExternalCAFile, "The external CA file for the service provider cluster. If not specified, service account's CA is used.") fs.StringVar(&options.TLSExternalServerName, "external-server-name", options.TLSExternalServerName, "The external (TLS) server name used by consumers to talk to the service provider cluster. This can be useful to select the right certificate via SNI.") + fs.StringVar(&options.Frontend, "frontend", options.Frontend, "If starts with http:// it is treated as a URL to a SPA server Else - it is treated as a path to static files to be served.") fs.StringVar(&options.Provider, "multicluster-runtime-provider", options.Provider, fmt.Sprintf("The multicluster runtime provider. Possible values are: %v", sets.List(sets.Set[string](sets.StringKeySet(providerAliases)))), diff --git a/backend/server.go b/backend/server.go index d35e5c73d..a01645a31 100644 --- a/backend/server.go +++ b/backend/server.go @@ -27,6 +27,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller" mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile" + "github.com/kube-bind/kube-bind/backend/auth" "github.com/kube-bind/kube-bind/backend/controllers/clusterbinding" "github.com/kube-bind/kube-bind/backend/controllers/serviceexport" "github.com/kube-bind/kube-bind/backend/controllers/serviceexportrequest" @@ -39,7 +40,7 @@ import ( type Server struct { Config *Config - OIDC *http.OIDCServiceProvider + OIDC *auth.OIDCServiceProvider Kubernetes *kube.Manager WebServer *http.Server @@ -67,9 +68,9 @@ func NewServer(ctx context.Context, c *Config) (*Server, error) { // setup oidc backend callback := c.Options.OIDC.CallbackURL if callback == "" { - callback = fmt.Sprintf("http://%s/callback", s.WebServer.Addr().String()) + callback = fmt.Sprintf("http://%s/api/callback", s.WebServer.Addr().String()) } - s.OIDC, err = http.NewOIDCServiceProvider( + s.OIDC, err = auth.NewOIDCServiceProvider( ctx, c.Options.OIDC.IssuerClientID, c.Options.OIDC.IssuerClientSecret, @@ -118,11 +119,14 @@ func NewServer(ctx context.Context, c *Config) (*Server, error) { c.Options.SchemaSource, kubebindv1alpha2.InformerScope(c.Options.ConsumerScope), s.Kubernetes, + c.Options.Frontend, ) if err != nil { return nil, fmt.Errorf("error setting up HTTP Handler: %w", err) } - handler.AddRoutes(s.WebServer.Router) + if err := handler.AddRoutes(s.WebServer.Router); err != nil { + return nil, fmt.Errorf("error adding routes to HTTP Server: %w", err) + } opts := controller.TypedOptions[mcreconcile.Request]{ SkipNameValidation: ptr.To(c.Options.TestingSkipNameValidation), diff --git a/backend/session/session.go b/backend/session/session.go index 387f73d93..ab38cd97b 100644 --- a/backend/session/session.go +++ b/backend/session/session.go @@ -29,7 +29,7 @@ import ( type State struct { Token TokenInfo `msgpack:"tok,omitempty"` SessionID string `msgpack:"sid,omitempty"` - ClusterID string `msgpack:"cl,omitempty"` + ClusterID string `msgpack:"cid,omitempty"` RedirectURL string `msgpack:"red,omitempty"` } diff --git a/backend/spaserver/spaserver.go b/backend/spaserver/spaserver.go new file mode 100644 index 000000000..92dbb9c33 --- /dev/null +++ b/backend/spaserver/spaserver.go @@ -0,0 +1,116 @@ +/* +Copyright 2025 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 spaserver + +import ( + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "strings" +) + +type SPAFileServer struct { + fileSystem http.FileSystem + fileServer http.Handler +} + +func NewSPAFileServer(fileSystem http.FileSystem) *SPAFileServer { + return &SPAFileServer{ + fileSystem: fileSystem, + fileServer: http.FileServer(fileSystem), + } +} + +func (s *SPAFileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path, err := filepath.Abs(r.URL.Path) + if err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Try to serve the file directly first + if f, err := s.fileSystem.Open(path); err == nil { + if err = f.Close(); err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + s.fileServer.ServeHTTP(w, r) + return + } else if !os.IsNotExist(err) { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // File not found - check if it's a static asset + if isStaticAsset(path) { + if f, err := s.fileSystem.Open(path); err == nil { + if err = f.Close(); err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + r.URL.Path = path + s.fileServer.ServeHTTP(w, r) + return + } + // Asset not found even with clean path, return 404 + http.NotFound(w, r) + return + } + + // For navigation routes, serve index.html + r.URL.Path = "/" + s.fileServer.ServeHTTP(w, r) +} + +// isStaticAsset checks if the path is for a static asset. +func isStaticAsset(path string) bool { + return strings.Contains(path, "/assets/") || + strings.HasSuffix(path, ".js") || + strings.HasSuffix(path, ".css") || + strings.HasSuffix(path, ".png") || + strings.HasSuffix(path, ".jpg") || + strings.HasSuffix(path, ".jpeg") || + strings.HasSuffix(path, ".gif") || + strings.HasSuffix(path, ".svg") || + strings.HasSuffix(path, ".ico") || + strings.HasSuffix(path, ".woff") || + strings.HasSuffix(path, ".woff2") || + strings.HasSuffix(path, ".ttf") || + strings.HasSuffix(path, ".eot") +} + +// SPAReverseProxyServer is used for local development or in theory it could be used. +type SPAReverseProxyServer struct { + reverseProxy *httputil.ReverseProxy +} + +func NewSPAReverseProxyServer(frontend string) (*SPAReverseProxyServer, error) { + u, err := url.Parse(frontend) + if err != nil { + return nil, err + } + + return &SPAReverseProxyServer{ + reverseProxy: httputil.NewSingleHostReverseProxy(u), + }, nil +} + +func (s *SPAReverseProxyServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.reverseProxy.ServeHTTP(w, r) +} diff --git a/cli/cmd/kubectl-bind/cmd/kubectlBind.go b/cli/cmd/kubectl-bind/cmd/kubectlBind.go index a8b53a64c..2b57f4c58 100644 --- a/cli/cmd/kubectl-bind/cmd/kubectlBind.go +++ b/cli/cmd/kubectl-bind/cmd/kubectlBind.go @@ -27,6 +27,9 @@ import ( "k8s.io/klog/v2" apiservicecmd "github.com/kube-bind/kube-bind/cli/pkg/kubectl/bind-apiservice/cmd" + collectionscmd "github.com/kube-bind/kube-bind/cli/pkg/kubectl/bind-collections/cmd" + logincmd "github.com/kube-bind/kube-bind/cli/pkg/kubectl/bind-login/cmd" + templatescmd "github.com/kube-bind/kube-bind/cli/pkg/kubectl/bind-templates/cmd" bindcmd "github.com/kube-bind/kube-bind/cli/pkg/kubectl/bind/cmd" ) @@ -54,5 +57,26 @@ func KubectlBindCommand() *cobra.Command { } rootCmd.AddCommand(apiserviceCmd) + loginCmd, err := logincmd.New(genericclioptions.IOStreams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr}) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v", err) + os.Exit(1) + } + rootCmd.AddCommand(loginCmd) + + templatesCmd, err := templatescmd.New(genericclioptions.IOStreams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr}) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v", err) + os.Exit(1) + } + rootCmd.AddCommand(templatesCmd) + + collectionsCmd, err := collectionscmd.New(genericclioptions.IOStreams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr}) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v", err) + os.Exit(1) + } + rootCmd.AddCommand(collectionsCmd) + return rootCmd } diff --git a/cli/go.mod b/cli/go.mod index cf725f797..d862666b5 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -13,7 +13,6 @@ require ( github.com/fatih/color v1.18.0 github.com/kube-bind/kube-bind v0.0.0-00010101000000-000000000000 github.com/kube-bind/kube-bind/sdk v0.4.1 - github.com/mdp/qrterminal/v3 v3.2.0 github.com/muesli/reflow v0.3.0 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 @@ -120,7 +119,6 @@ require ( k8s.io/apiserver v0.33.3 // indirect k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect - rsc.io/qr v0.2.0 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/kustomize/api v0.19.0 // indirect diff --git a/cli/go.sum b/cli/go.sum index eec974069..9703b0c34 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -120,8 +120,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxmAOow= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mdp/qrterminal/v3 v3.2.0 h1:qteQMXO3oyTK4IHwj2mWsKYYRBOp1Pj2WRYFYYNTCdk= -github.com/mdp/qrterminal/v3 v3.2.0/go.mod h1:XGGuua4Lefrl7TLEsSONiD+UEjQXJZ4mPzF+gWYIJkk= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -330,8 +328,6 @@ k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUy k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= -rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= diff --git a/cli/pkg/client/client.go b/cli/pkg/client/client.go new file mode 100644 index 000000000..9571b4838 --- /dev/null +++ b/cli/pkg/client/client.go @@ -0,0 +1,255 @@ +/* +Copyright 2025 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 client + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/kube-bind/kube-bind/cli/pkg/config" + kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" +) + +type Client interface { + GetTemplates(context.Context) (*kubebindv1alpha2.APIServiceExportTemplateList, error) + GetCollections(context.Context) (*kubebindv1alpha2.CollectionList, error) + Bind(context.Context, *kubebindv1alpha2.BindableResourcesRequest) (*kubebindv1alpha2.BindingResourceResponse, error) + + Get(string) (*http.Response, error) + Post(string, io.Reader) (*http.Response, error) + Do(*http.Request) (*http.Response, error) +} + +// authenticatedClient provides an HTTP client with automatic authentication +type authenticatedClient struct { + client *http.Client + server config.Server + + insecure bool +} + +type ClientOption func(*authenticatedClient) + +// WithInsecure configures the client to skip TLS certificate verification +// WARNING: This should only be used for testing or development environments +func WithInsecure(insecure bool) ClientOption { + return func(c *authenticatedClient) { + c.insecure = insecure + } +} + +// NewClient creates a new authenticated HTTP client +func NewClient(server config.Server, opts ...ClientOption) (Client, error) { + authClient := &authenticatedClient{ + server: server, + } + for _, opt := range opts { + opt(authClient) + } + + if authClient.client == nil { + authClient.client = &http.Client{} + } + + // Create an insecure HTTP client if needed + if authClient.insecure { + authClient.client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + } + + return authClient, nil +} + +func (c *authenticatedClient) GetTemplates(ctx context.Context) (*kubebindv1alpha2.APIServiceExportTemplateList, error) { + url, err := c.buildEndpointURL("templates") + if err != nil { + return nil, fmt.Errorf("failed to build templates URL: %w", err) + } + + resp, err := c.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result kubebindv1alpha2.APIServiceExportTemplateList + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode templates response: %w", err) + } + + return &result, nil +} + +func (c *authenticatedClient) GetCollections(ctx context.Context) (*kubebindv1alpha2.CollectionList, error) { + url, err := c.buildEndpointURL("collections") + if err != nil { + return nil, fmt.Errorf("failed to build collections URL: %w", err) + } + + resp, err := c.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result kubebindv1alpha2.CollectionList + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode collections response: %w", err) + } + + return &result, nil +} + +func (c *authenticatedClient) Bind(ctx context.Context, request *kubebindv1alpha2.BindableResourcesRequest) (*kubebindv1alpha2.BindingResourceResponse, error) { + url, err := c.buildEndpointURL("bind") + if err != nil { + return nil, fmt.Errorf("failed to build bind URL: %w", err) + } + + reqBody, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal bind request: %w", err) + } + + resp, err := c.Post(url, io.NopCloser(bytes.NewReader(reqBody))) + if err != nil { + return nil, fmt.Errorf("failed to send bind request: %w", err) + } + defer resp.Body.Close() + + var result kubebindv1alpha2.BindingResourceResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode bind response: %w", err) + } + + return &result, nil +} + +// Get performs an authenticated GET request +func (c *authenticatedClient) Get(url string) (*http.Response, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + if err := c.addAuthHeaders(req); err != nil { + return nil, err + } + + return c.client.Do(req) +} + +// Post performs an authenticated POST request +func (c *authenticatedClient) Post(url string, body io.Reader) (*http.Response, error) { + req, err := http.NewRequest("POST", url, body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + if err := c.addAuthHeaders(req); err != nil { + return nil, err + } + + return c.client.Do(req) +} + +// Do performs an authenticated HTTP request +func (c *authenticatedClient) Do(req *http.Request) (*http.Response, error) { + if err := c.addAuthHeaders(req); err != nil { + return nil, err + } + + return c.client.Do(req) +} + +// addAuthHeaders adds authentication headers to the request +func (c *authenticatedClient) addAuthHeaders(req *http.Request) error { + s := c.server + // Check if we have stored authentication for this server + if !c.isTokenValid() { + err := fmt.Errorf("no valid authentication found for server %s. Please run 'kubectl bind login %s' first", s.URL, s.URL) + if s.Cluster != "" { + err = fmt.Errorf("no valid authentication found for server %s with cluster %s. Please run 'kubectl bind login %s --cluster %s' first", s.URL, s.Cluster, s.URL, s.Cluster) + } + return err + } + + req.Header.Set("Authorization", c.getAuthorizationHeader()) + req.Header.Set("User-Agent", "kubectl-bind-cli") + req.Header.Set("Accept", "application/json") + + return nil +} + +// isTokenValid checks if the token for the given server is still valid +func (c *authenticatedClient) isTokenValid() bool { + + if c.server.AccessToken == "" { + return false + } + + // Check if token has expired (with 5 minute buffer) + if !c.server.ExpiresAt.IsZero() && time.Now().Add(5*time.Minute).After(c.server.ExpiresAt) { + return false + } + + return true +} + +// buildEndpointURL constructs an endpoint URL based on server config and cluster context +func (c *authenticatedClient) buildEndpointURL(endpoint string) (string, error) { + baseURL, err := url.Parse(c.server.URL) + if err != nil { + return "", fmt.Errorf("invalid server URL %q: %w", c.server.URL, err) + } + if baseURL.Scheme == "" || baseURL.Host == "" { + return "", fmt.Errorf("invalid server URL %q: missing scheme or host", c.server.URL) + } + + baseURL.Path = path.Join(baseURL.Path, "api", strings.TrimPrefix(endpoint, "/")) + + if c.server.Cluster != "" { + query := baseURL.Query() + query.Set("cluster_id", c.server.Cluster) + baseURL.RawQuery = query.Encode() + } + + return baseURL.String(), nil +} + +// getAuthorizationHeader returns the authorization header value for the given server +func (c *authenticatedClient) getAuthorizationHeader() string { + tokenType := c.server.TokenType + if tokenType == "" { + tokenType = "Bearer" + } + + return fmt.Sprintf("%s %s", tokenType, c.server.AccessToken) +} diff --git a/cli/pkg/config/config.go b/cli/pkg/config/config.go new file mode 100644 index 000000000..3f3b31af9 --- /dev/null +++ b/cli/pkg/config/config.go @@ -0,0 +1,208 @@ +/* +Copyright 2025 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 config + +import ( + "encoding/json" + "fmt" + "os" + "path" + "strings" + "time" +) + +// Config represents the kube-bind CLI configuration +type Config struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Servers map[string]*Server `json:"servers,omitempty"` + Current string `json:"current,omitempty"` + + // configFile is the path to the kube-bind configuration file + configFile string `json:"-"` +} + +// Server represents authentication details for a kube-bind server +type Server struct { + URL string `json:"url"` + Cluster string `json:"cluster,omitempty"` + AccessToken string `json:"accessToken,omitempty"` + TokenType string `json:"tokenType,omitempty"` + ExpiresAt time.Time `json:"expiresAt,omitempty"` + Username string `json:"username,omitempty"` +} + +// NewConfig creates a new empty configuration +func NewConfig(configFile string) *Config { + // if configFile is empty, use default location + if configFile == "" { + configFile = GetDefaultConfigFilePath() + } + return &Config{ + APIVersion: "v1", + Kind: "Config", + Servers: make(map[string]*Server), + configFile: configFile, + } +} + +// GetDefaultConfigFilePath returns the default path to the kube-bind config file +func GetDefaultConfigFilePath() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return path.Join(".", "config") + } + return path.Join(homeDir, ".kube-bind", "config") +} + +// GetConfigPath returns the path to the kube-bind config file +func (c *Config) GetConfigPath() (string, error) { + return c.configFile, nil +} + +// LoadConfig loads the kube-bind configuration from file +func LoadConfigFromFile(configFile string) (*Config, error) { + // If config file doesn't exist, return empty config + if _, err := os.Stat(configFile); os.IsNotExist(err) { + return NewConfig(configFile), nil + } + + data, err := os.ReadFile(configFile) + if err != nil { + return nil, fmt.Errorf("unable to read config file: %w", err) + } + + var config Config + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("unable to parse config file: %w", err) + } + config.configFile = configFile + + // Initialize servers map if nil + if config.Servers == nil { + config.Servers = make(map[string]*Server) + } + + return &config, nil +} + +// SaveConfig saves the kube-bind configuration to file +func (c *Config) SaveConfig() error { + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return fmt.Errorf("unable to marshal config: %w", err) + } + + configFile, err := c.GetConfigPath() + if err != nil { + return fmt.Errorf("unable to get config file path: %w", err) + } + + if err := os.WriteFile(configFile, data, 0600); err != nil { + return fmt.Errorf("unable to write config file %s: %w", configFile, err) + } + + return nil +} + +func (c *Config) Get(serverURL, clusterID string) (*Server, bool) { + key := buildServerKey(serverURL, clusterID) + server, exists := c.Servers[key] + return server, exists +} + +// AddServerWithCluster adds or updates server configuration with cluster ID +func (c *Config) AddServerWithCluster(serverURL, clusterID string, server *Server) { + key := buildServerKey(serverURL, clusterID) + c.addServer(key, server) +} + +// addServer adds or updates server configuration +func (c *Config) addServer(name string, server *Server) { + if c.Servers == nil { + c.Servers = make(map[string]*Server) + } + c.Servers[name] = server + c.Current = name +} + +// FindServersByURL finds all servers for a given URL (across all clusters) +func (c *Config) FindServersByURL(serverURL string) map[string]*Server { + matches := make(map[string]*Server) + for key, server := range c.Servers { + keyServerURL, _ := parseServerKey(key) + if keyServerURL == serverURL { + matches[key] = server + } + } + return matches +} + +// RemoveServer removes a server from the configuration +func (c *Config) RemoveServer(name, cluster string) { + delete(c.Servers, buildServerKey(name, cluster)) + if c.Current == name { + c.Current = "" + // Set current to first available server + for serverName := range c.Servers { + c.Current = serverName + break + } + } +} + +// GetCurrentServer returns the currently active server configuration. +func (c *Config) GetCurrentServer() (*Server, string, error) { + if c.Current == "" { + return nil, "", fmt.Errorf("no current server set") + } + + server, exists := c.Servers[c.Current] + if !exists { + return nil, "", fmt.Errorf("current server %q not found in config", c.Current) + } + + return server, c.Current, nil +} + +// SetCurrentServer sets the current active server +func (c *Config) SetCurrentServer(serverName, cluster string) error { + if _, exists := c.Servers[buildServerKey(serverName, cluster)]; !exists { + return fmt.Errorf("server %q does not exist", serverName) + } + + c.Current = buildServerKey(serverName, cluster) + return nil +} + +// buildServerKey creates a unique key for server+cluster combination +func buildServerKey(serverURL, clusterID string) string { + if clusterID == "" { + return serverURL + } + + // Use @ separator to combine server and cluster (similar to user@host pattern) + return fmt.Sprintf("%s@%s", clusterID, serverURL) +} + +// parseServerKey parses a server key back into serverURL and clusterID. +func parseServerKey(key string) (serverURL, clusterID string) { + if parts := strings.SplitN(key, "@", 2); len(parts) == 2 { + return parts[1], parts[0] + } + return key, "" +} diff --git a/cli/pkg/kubectl/base/browser.go b/cli/pkg/kubectl/base/browser.go new file mode 100644 index 000000000..ab64731bf --- /dev/null +++ b/cli/pkg/kubectl/base/browser.go @@ -0,0 +1,49 @@ +/* +Copyright 2025 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 base + +import ( + "os/exec" + "strings" +) + +// openBrowser opens the given URL in the default browser +func OpenBrowser(url string) error { + var cmd *exec.Cmd + + // Determine the command based on the operating system + switch { + case isWindows(): + cmd = exec.Command("cmd", "/c", "start", url) + case isMacOS(): + cmd = exec.Command("open", url) + default: // Linux and other Unix-like systems + cmd = exec.Command("xdg-open", url) + } + + return cmd.Run() +} + +// isWindows checks if the current OS is Windows +func isWindows() bool { + return strings.Contains(strings.ToLower(exec.Command("uname").String()), "windows") +} + +// isMacOS checks if the current OS is macOS +func isMacOS() bool { + return strings.Contains(strings.ToLower(exec.Command("uname").String()), "darwin") +} diff --git a/cli/pkg/kubectl/base/client.go b/cli/pkg/kubectl/base/client.go new file mode 100644 index 000000000..18fea8bb1 --- /dev/null +++ b/cli/pkg/kubectl/base/client.go @@ -0,0 +1,30 @@ +/* +Copyright 2025 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 base + +import ( + "github.com/kube-bind/kube-bind/cli/pkg/client" +) + +// GetAuthenticatedClientWithLogin returns an authenticated client for the configured server. +func (o *Options) GetAuthenticatedClient() (client.Client, error) { + // First try to create an authenticated client + // Now use authenticated client + return client.NewClient(*o.server, + client.WithInsecure(o.SkipInsecure), + ) +} diff --git a/cli/pkg/kubectl/base/options.go b/cli/pkg/kubectl/base/options.go index 9e4ef0046..2eef694fb 100644 --- a/cli/pkg/kubectl/base/options.go +++ b/cli/pkg/kubectl/base/options.go @@ -17,13 +17,22 @@ limitations under the License. package base import ( + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "github.com/spf13/cobra" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/client-go/tools/clientcmd" + + "github.com/kube-bind/kube-bind/cli/pkg/config" ) // Options contains options common to most CLI plugins. type Options struct { + genericclioptions.IOStreams // OptOutOfDefaultKubectlFlags indicates that the standard kubectl/kubeconfig-related flags should not be bound // by default. OptOutOfDefaultKubectlFlags bool @@ -31,12 +40,22 @@ type Options struct { Kubeconfig string // KubectlOverrides stores the extra client connection fields, such as context, user, etc. KubectlOverrides *clientcmd.ConfigOverrides - - genericclioptions.IOStreams - + // ServerName is the kube-bind server name to use (overrides kube-bind config current server) + ServerName string + // ClusterName is the kube-bind cluster name to use (overrides kube-bind config current cluster) + ClusterName string + // SkipInsecure skips TLS verification (for development) + SkipInsecure bool + // ConfigFile is the path to the kube-bind configuration file + ConfigFile string // ClientConfig is the resolved cliendcmd.ClientConfig based on the client connection flags. This is only valid // after calling Complete. ClientConfig clientcmd.ClientConfig + + // config is config struct. Used only for login process. + config *config.Config + // Server is the loaded kube-bind server used by the plugins. + server *config.Server } // NewOptions provides an instance of Options with default values. @@ -66,10 +85,16 @@ func (o *Options) BindFlags(cmd *cobra.Command) { kubectlConfigOverrideFlags.Timeout.LongName = "" clientcmd.BindOverrideFlags(o.KubectlOverrides, cmd.PersistentFlags(), kubectlConfigOverrideFlags) + + // Add common kube-bind flags + cmd.Flags().StringVar(&o.ServerName, "server", o.ServerName, "The kube-bind server name to use (overrides kube-bind config current server)") + cmd.Flags().StringVar(&o.ClusterName, "cluster", o.ClusterName, "The kube-bind cluster name to use (overrides kube-bind config current cluster)") + cmd.Flags().BoolVar(&o.SkipInsecure, "insecure-skip-tls-verify", false, "Skip TLS certificate verification (not recommended)") + cmd.Flags().StringVar(&o.ConfigFile, "config-file", "", "Path to the kube-bind configuration file") } -// Complete initializes ClientConfig based on Kubeconfig and KubectlOverrides. -func (o *Options) Complete() error { +// Complete initializes ClientConfig based on Kubeconfig and KubectlOverrides, and resolves server configuration. +func (o *Options) Complete(skipValidate bool) error { loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() loadingRules.ExplicitPath = o.Kubeconfig @@ -80,10 +105,100 @@ func (o *Options) Complete() error { o.ClientConfig = clientcmd.NewDefaultClientConfig(*startingConfig, o.KubectlOverrides) + if o.ConfigFile == "" { + o.ConfigFile = config.GetDefaultConfigFilePath() + } + + // Create directory if it doesn't exist + if err := os.MkdirAll(filepath.Dir(o.ConfigFile), 0700); err != nil { + return fmt.Errorf("unable to create config directory: %w", err) + } + + if _, err := os.Stat(o.ConfigFile); os.IsNotExist(err) { + // Create empty config file + emptyConfig := config.NewConfig(o.ConfigFile) + if err := emptyConfig.SaveConfig(); err != nil { + return fmt.Errorf("unable to create config file: %w", err) + } + } + + switch { + case o.ServerName != "" && o.ClusterName != "": + // Both server and cluster specified separately - do nothing + case o.ServerName != "" && strings.Contains(o.ServerName, "@"): + // Server specified in server@cluster format + parts := strings.SplitN(o.ServerName, "@", 2) + o.ServerName = parts[0] + o.ClusterName = parts[1] + case o.ServerName != "" && o.ClusterName == "": + // Only server specified - do nothing. Cluster is empty + case o.ServerName == "": + // No server specified - do nothing. Will be resolved from config + } + + // If server name is not specified, use current server from config. + // Server should always be set either with cluster or without. + // So if its empty here, we need to resolve it. + var s *config.Server + var c *config.Config + if o.ServerName == "" { + c, err = config.LoadConfigFromFile(o.ConfigFile) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + s, _, err = c.GetCurrentServer() + if err != nil && !skipValidate { + return fmt.Errorf("no current server configured. Use 'kubectl bind-login {--cluster }' to login first: %w", err) + } + } else { + // Validate that the specified server exists in config + c, err = config.LoadConfigFromFile(o.ConfigFile) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + var exists bool + s, exists = c.Get(o.ServerName, o.ClusterName) + if !exists && !skipValidate { + err := fmt.Errorf("server %q not found in config", o.ServerName) + if o.ClusterName != "" { + err = fmt.Errorf("server %q with cluster %q not found in config", o.ServerName, o.ClusterName) + } + return err + } + } + if (s == nil && !skipValidate) || c == nil { + // This should never happen. This is programming error. + return fmt.Errorf("failed to resolve server configuration") + } + + if s != nil { + o.ServerName = s.URL + o.ClusterName = s.Cluster + } + o.server = s + o.config = c + return nil } // Validate validates the configured options. func (o *Options) Validate() error { + if o.ServerName != "" && strings.Contains(o.ServerName, "@") && o.ClusterName != "" { + return fmt.Errorf("cannot specify both server in 'server@cluster' format and --cluster flag") + } + + if o.ClusterName != "" && o.ServerName == "" { + return fmt.Errorf("cannot specify --cluster without --server") + } + if _, err := url.Parse(o.ServerName); err != nil { + return fmt.Errorf("invalid server URL: %w", err) + } + return nil } + +func (o *Options) GetConfig() *config.Config { + return o.config +} diff --git a/cli/pkg/kubectl/bind-apiservice/plugin/bind.go b/cli/pkg/kubectl/bind-apiservice/plugin/bind.go index 240a271e0..f57ded8ac 100644 --- a/cli/pkg/kubectl/bind-apiservice/plugin/bind.go +++ b/cli/pkg/kubectl/bind-apiservice/plugin/bind.go @@ -20,23 +20,21 @@ import ( "context" "errors" "fmt" - "io" - "net/http" - "net/url" - "os" "strings" "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" kubeclient "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" "k8s.io/component-base/logs" logsv1 "k8s.io/component-base/logs/api/v1" - "sigs.k8s.io/yaml" "github.com/kube-bind/kube-bind/cli/pkg/kubectl/base" kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" @@ -44,12 +42,13 @@ import ( // BindAPIServiceOptions are the options for the kubectl-bind-apiservice command. type BindAPIServiceOptions struct { - Options *base.Options - Logs *logs.Options + *base.Options + Logs *logs.Options JSONYamlPrintFlags *genericclioptions.JSONYamlPrintFlags OutputFormat string Print *genericclioptions.PrintFlags + printer printers.ResourcePrinter remoteKubeconfigFile string remoteKubeconfigNamespace string @@ -62,8 +61,9 @@ type BindAPIServiceOptions struct { KonnectorImageOverride string DowngradeKonnector bool NoBanner bool - - url string + DryRun bool + Template string + Name string } // NewBindAPIServiceOptions returns new BindAPIServiceOptions. @@ -81,6 +81,8 @@ func (b *BindAPIServiceOptions) AddCmdFlags(cmd *cobra.Command) { logsv1.AddFlags(b.Logs, cmd.Flags()) b.Print.AddFlags(cmd) + cmd.Flags().StringVar(&b.Template, "template-name", b.Template, "A template name to use for binding") + cmd.Flags().StringVar(&b.Name, "name", b.Name, "The name of the BindableResourcesRequest to create") cmd.Flags().StringVar(&b.remoteKubeconfigFile, "remote-kubeconfig", b.remoteKubeconfigFile, "A file path for a kubeconfig file to connect to the service provider cluster") cmd.Flags().StringVar(&b.remoteKubeconfigNamespace, "remote-kubeconfig-namespace", b.remoteKubeconfigNamespace, "The namespace of the remote kubeconfig secret to read from") cmd.Flags().StringVar(&b.remoteKubeconfigName, "remote-kubeconfig-name", b.remoteKubeconfigNamespace, "The name of the remote kubeconfig secret to read from") @@ -89,6 +91,7 @@ func (b *BindAPIServiceOptions) AddCmdFlags(cmd *cobra.Command) { cmd.Flags().BoolVar(&b.SkipKonnector, "skip-konnector", b.SkipKonnector, "Skip the deployment of the konnector") cmd.Flags().BoolVar(&b.DowngradeKonnector, "downgrade-konnector", b.DowngradeKonnector, "Downgrade the konnector to the version of the kubectl-bind-apiservice binary") cmd.Flags().StringVar(&b.KonnectorImageOverride, "konnector-image", b.KonnectorImageOverride, "The konnector image to use") + cmd.Flags().BoolVarP(&b.DryRun, "dry-run", "d", b.DryRun, "If true, only print the requests that would be sent to the service provider after authentication, without actually binding.") cmd.Flags().MarkHidden("konnector-image") //nolint:errcheck cmd.Flags().BoolVar(&b.NoBanner, "no-banner", b.NoBanner, "Do not show the red banner") cmd.Flags().MarkHidden("no-banner") //nolint:errcheck @@ -96,46 +99,44 @@ func (b *BindAPIServiceOptions) AddCmdFlags(cmd *cobra.Command) { // Complete ensures all fields are initialized. func (b *BindAPIServiceOptions) Complete(args []string) error { - if err := b.Options.Complete(); err != nil { + if len(args) > 0 { + b.Name = args[0] + } + if err := b.Options.Complete(false); err != nil { return err } - if len(args) > 0 { - b.url = args[0] + printer, err := b.Print.ToPrinter() + if err != nil { + return err } + + b.printer = printer return nil } // Validate validates the BindAPIServiceOptions are complete and usable. func (b *BindAPIServiceOptions) Validate() error { - if b.url == "" && b.file == "" { - return errors.New("url or file is required") - } - if b.url != "" && b.file != "" { - return errors.New("url and file are mutually exclusive") - } - if b.url != "" { - if _, err := url.Parse(b.url); err != nil { - return fmt.Errorf("invalid url %q: %w", b.url, err) - } + if b.file == "" && b.Template == "" { + return errors.New("file, template-name or --file are required") } if allowed := sets.NewString(b.Print.AllowedFormats()...); *b.Print.OutputFormat != "" && !allowed.Has(*b.Print.OutputFormat) { return fmt.Errorf("invalid output format %q (allowed: %s)", *b.Print.OutputFormat, strings.Join(allowed.List(), ", ")) } - if (b.remoteKubeconfigNamespace == "" && b.remoteKubeconfigName != "") || - (b.remoteKubeconfigNamespace != "" && b.remoteKubeconfigName == "") { - return errors.New("remote-kubeconfig-namespace and remote-kubeconfig-name must be specified together") - } - if b.remoteKubeconfigFile == "" && b.remoteKubeconfigNamespace == "" && b.remoteKubeconfigName == "" { - return errors.New("remote-kubeconfig or remote-kubeconfig-namespace and remote-kubeconfig-name are required") - } - if b.file != "" && b.url != "" { - return errors.New("file and arguments are mutually exclusive") + if b.Template == "" { + if (b.remoteKubeconfigNamespace == "" && b.remoteKubeconfigName != "") || + (b.remoteKubeconfigNamespace != "" && b.remoteKubeconfigName == "") { + return errors.New("remote-kubeconfig-namespace and remote-kubeconfig-name must be specified together") + } + if b.remoteKubeconfigFile == "" && b.remoteKubeconfigNamespace == "" && b.remoteKubeconfigName == "" { + return errors.New("remote-kubeconfig or remote-kubeconfig-namespace and remote-kubeconfig-name are required") + } } - if b.file == "" && b.url == "" { - return errors.New("file or arguments are required") + // Name is required unless reading from file, where name will be read from the file. + if b.Name == "" && b.file == "" { + return errors.New("name is required") } return b.Options.Validate() @@ -143,95 +144,73 @@ func (b *BindAPIServiceOptions) Validate() error { // Run starts the binding process. func (b *BindAPIServiceOptions) Run(ctx context.Context) error { - fmt.Fprintf(b.Options.ErrOut, "🔧 Starting binding process...\n") + fmt.Fprintf(b.Options.ErrOut, "Starting binding process...\n") - fmt.Fprintf(b.Options.ErrOut, "📋 Step 1: Getting client config...\n") config, err := b.Options.ClientConfig.ClientConfig() if err != nil { - fmt.Fprintf(b.Options.ErrOut, "❌ Failed to get client config: %v\n", err) - return err - } - fmt.Fprintf(b.Options.ErrOut, "✅ Client config obtained successfully\n") - - fmt.Fprintf(b.Options.ErrOut, "📋 Step 2: Getting remote kubeconfig...\n") - remoteKubeconfig, remoteNamespace, remoteConfig, err := b.getRemoteKubeconfig(ctx, config) - if err != nil { - fmt.Fprintf(b.Options.ErrOut, "❌ Failed to get remote kubeconfig: %v\n", err) - return err - } - fmt.Fprintf(b.Options.ErrOut, "✅ Remote kubeconfig obtained, namespace: %s\n", remoteNamespace) - - fmt.Fprintf(b.Options.ErrOut, "📋 Step 3: Getting request manifest...\n") - bs, err := b.getRequestManifest() - if err != nil { - fmt.Fprintf(b.Options.ErrOut, "❌ Failed to get request manifest: %v\n", err) - return err - } - fmt.Fprintf(b.Options.ErrOut, "✅ Request manifest obtained (%d bytes)\n", len(bs)) - - fmt.Fprintf(b.Options.ErrOut, "📋 Step 4: Unmarshaling manifest...\n") - request, err := b.unmarshalManifest(bs) - if err != nil { - fmt.Fprintf(b.Options.ErrOut, "❌ Failed to unmarshal manifest: %v\n", err) - return err - } - fmt.Fprintf(b.Options.ErrOut, "✅ Manifest unmarshaled successfully\n") - - fmt.Fprintf(b.Options.ErrOut, "📋 Step 5: Creating service export request...\n") - result, err := b.createServiceExportRequest(ctx, remoteConfig, remoteNamespace, request) - if err != nil { - fmt.Fprintf(b.Options.ErrOut, "❌ Failed to create service export request: %v\n", err) return err } - fmt.Fprintf(b.Options.ErrOut, "✅ Service export request created successfully\n") - fmt.Fprintf(b.Options.ErrOut, "📋 Step 6: Deploying konnector...\n") - if err := b.deployKonnector(ctx, config); err != nil { - fmt.Fprintf(b.Options.ErrOut, "❌ Failed to deploy konnector: %v\n", err) - return err - } - fmt.Fprintf(b.Options.ErrOut, "✅ Konnector deployed successfully\n") - - fmt.Fprintf(b.Options.ErrOut, "📋 Step 7: Creating kubeconfig secret...\n") - secretName, err := b.createKubeconfigSecret(ctx, config, remoteConfig.Host, remoteNamespace, remoteKubeconfig) - if err != nil { - fmt.Fprintf(b.Options.ErrOut, "❌ Failed to create kubeconfig secret: %v\n", err) - return err + // Use the shared binder to create bindings + binderOpts := &BinderOptions{ + IOStreams: b.Options.IOStreams, + SkipKonnector: b.SkipKonnector, + KonnectorImageOverride: b.KonnectorImageOverride, + DowngradeKonnector: b.DowngradeKonnector, + RemoteKubeconfigFile: b.remoteKubeconfigFile, + RemoteKubeconfigNamespace: b.remoteKubeconfigNamespace, + RemoteKubeconfigName: b.remoteKubeconfigName, + RemoteNamespace: b.remoteNamespace, + File: b.file, } - fmt.Fprintf(b.Options.ErrOut, "✅ Kubeconfig secret created: %s\n", secretName) + binder := NewBinder(config, binderOpts) - fmt.Fprintf(b.Options.ErrOut, "📋 Step 8: Creating API service bindings...\n") - bindings, err := b.createAPIServiceBindings(ctx, config, result, secretName) - if err != nil { - fmt.Fprintf(b.Options.ErrOut, "❌ Failed to create API service bindings: %v\n", err) - return err + var bindings []*kubebindv1alpha2.APIServiceBinding + if b.Template != "" { + r, err := b.bindTemplate(ctx) + if err != nil { + return err + } + bindings, err = binder.BindFromResponse(ctx, r.response) + if err != nil { + return fmt.Errorf("failed to create bindings: %w", err) + } + } else if b.file != "" { + bindings, err = binder.BindFromFile(ctx) + if err != nil { + return fmt.Errorf("failed to create bindings: %w", err) + } } - fmt.Fprintf(b.Options.ErrOut, "✅ API service bindings created (%d bindings)\n", len(bindings)) - fmt.Fprintf(b.Options.ErrOut, "📋 Step 9: Printing results table...\n") fmt.Fprintln(b.Options.ErrOut) return b.printTable(ctx, config, bindings) } -func (b *BindAPIServiceOptions) getRemoteKubeconfig(ctx context.Context, config *rest.Config) (kubeconfig, ns string, remoteConfig *rest.Config, err error) { +func (b *BindAPIServiceOptions) getRemoteKubeconfig(ctx context.Context, config *rest.Config, namespace, name string) (kubeconfig, ns string, remoteConfig *rest.Config, err error) { var remoteKubeConfig *clientcmdapi.Config - if b.remoteKubeconfigFile != "" { + + switch { + case b.remoteKubeconfigFile != "": remoteKubeConfig, err = clientcmd.LoadFromFile(b.remoteKubeconfigFile) if err != nil { return "", "", nil, err } - } else { + case b.remoteKubeconfigNamespace != "" && b.remoteKubeconfigName != "": + name = b.remoteKubeconfigName + namespace = b.remoteKubeconfigNamespace + fallthrough + default: kubeClient, err := kubeclient.NewForConfig(config) if err != nil { return "", "", nil, err } - secret, err := kubeClient.CoreV1().Secrets(b.remoteKubeconfigNamespace).Get(ctx, b.remoteKubeconfigName, metav1.GetOptions{}) + secret, err := kubeClient.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{}) if err != nil { return "", "", nil, err } bs, found := secret.Data["kubeconfig"] if !found { - return "", "", nil, fmt.Errorf("secret %s/%s does not contain a kubeconfig", b.remoteKubeconfigNamespace, b.remoteKubeconfigName) + return "", "", nil, fmt.Errorf("secret %s/%s does not contain a kubeconfig", namespace, name) } remoteKubeConfig, err = clientcmd.Load(bs) if err != nil { @@ -261,45 +240,97 @@ func (b *BindAPIServiceOptions) getRemoteKubeconfig(ctx context.Context, config return string(remoteKubeconfig), c.Namespace, remoteConfig, nil } -func (b *BindAPIServiceOptions) getRequestManifest() ([]byte, error) { - if b.url != "" { - resp, err := http.Get(b.url) //nolint:noctx - if err != nil { - return nil, fmt.Errorf("failed to get %s: %w", b.url, err) +func (b *BindAPIServiceOptions) ensureClientSideNamespaceExists(ctx context.Context, config *rest.Config) error { + kubeClient, err := kubeclient.NewForConfig(config) + if err != nil { + return err + } + + _, err = kubeClient.CoreV1().Namespaces().Get(ctx, "kube-bind", metav1.GetOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + return err + } else if apierrors.IsNotFound(err) { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-bind", + }, } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + if _, err = kubeClient.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}); err != nil { + return err + } else { + fmt.Fprintf(b.Options.IOStreams.ErrOut, "Created kube-bind namespace.\n") } - return body, nil } + return nil +} - if b.file == "-" { - body, err := io.ReadAll(b.Options.IOStreams.In) - if err != nil { - return nil, fmt.Errorf("failed to read from stdin: %w", err) - } - return body, nil +type bindTemplateResult struct { + response *kubebindv1alpha2.BindingResourceResponse + namespace string + name string +} + +func (b *BindAPIServiceOptions) bindTemplate(ctx context.Context) (*bindTemplateResult, error) { + config, err := b.Options.ClientConfig.ClientConfig() + if err != nil { + return nil, err } - body, err := os.ReadFile(b.file) + kubeClient, err := kubeclient.NewForConfig(config) if err != nil { - return nil, fmt.Errorf("failed to read file %s: %w", b.file, err) + return nil, fmt.Errorf("failed to create kube client: %w", err) + } + + // Get authenticated client with auto-login + client, err := b.Options.GetAuthenticatedClient() + if err != nil { + return nil, fmt.Errorf("failed to create authenticated client: %w", err) + } + + bindRequest := &kubebindv1alpha2.BindableResourcesRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: b.Name, + }, + TemplateRef: kubebindv1alpha2.APIServiceExportTemplateRef{ + Name: b.Template, + }, } - return body, nil -} -func (b *BindAPIServiceOptions) unmarshalManifest(bs []byte) (*kubebindv1alpha2.APIServiceExportRequest, error) { - var request kubebindv1alpha2.APIServiceExportRequest - if err := yaml.Unmarshal(bs, &request); err != nil { - return nil, fmt.Errorf("failed to unmarshal manifest: %w", err) + bindResponse, err := client.Bind(ctx, bindRequest) + if err != nil { + return nil, fmt.Errorf("failed to bind to template %q: %w", b.Template, err) } - if request.APIVersion != kubebindv1alpha2.SchemeGroupVersion.String() { - return nil, fmt.Errorf("invalid apiVersion %q", request.APIVersion) + + if bindResponse.Authentication.OAuth2CodeGrant == nil { + return nil, fmt.Errorf("unexpected response: authentication.oauth2CodeGrant is nil") } - if request.Kind != "APIServiceExportRequest" { - return nil, fmt.Errorf("invalid kind %q", request.Kind) + + err = b.ensureClientSideNamespaceExists(ctx, config) + if err != nil { + return nil, fmt.Errorf("failed to ensure kube-bind namespace exists: %w", err) + } + + // copy kubeconfig into local cluster + remoteHost, remoteNamespace, err := base.ParseRemoteKubeconfig(bindResponse.Kubeconfig) + if err != nil { + return nil, err + } + secretName, err := base.FindRemoteKubeconfig(ctx, kubeClient, remoteNamespace, remoteHost) + if err != nil { + return nil, err + } + secret, created, err := base.EnsureKubeconfigSecret(ctx, string(bindResponse.Kubeconfig), secretName, kubeClient) + if err != nil { + return nil, err + } + if created { + fmt.Fprintf(b.Options.IOStreams.ErrOut, "Created secret %s/%s for host %s, namespace %s\n", "kube-bind", secret.Name, remoteHost, remoteNamespace) + } else { + fmt.Fprintf(b.Options.IOStreams.ErrOut, "Updated secret %s/%s for host %s, namespace %s\n", "kube-bind", secret.Name, remoteHost, remoteNamespace) } - return &request, nil + return &bindTemplateResult{ + response: bindResponse, + namespace: secret.Namespace, + name: secret.Name, + }, nil } diff --git a/cli/pkg/kubectl/bind-apiservice/plugin/binder.go b/cli/pkg/kubectl/bind-apiservice/plugin/binder.go new file mode 100644 index 000000000..fbf4799a1 --- /dev/null +++ b/cli/pkg/kubectl/bind-apiservice/plugin/binder.go @@ -0,0 +1,328 @@ +/* +Copyright 2025 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 plugin + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + kubeclient "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "sigs.k8s.io/yaml" + + "github.com/kube-bind/kube-bind/cli/pkg/kubectl/base" + kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" +) + +// BinderOptions contains the configuration for the shared binder +type BinderOptions struct { + IOStreams genericclioptions.IOStreams + SkipKonnector bool + KonnectorImageOverride string + DowngradeKonnector bool + RemoteKubeconfigFile string + RemoteKubeconfigNamespace string + RemoteKubeconfigName string + RemoteNamespace string + File string + DryRun bool +} + +// Binder provides shared binding functionality for both bind and bind-apiservice commands +type Binder struct { + opts *BinderOptions + config *rest.Config +} + +// NewBinder creates a new shared binder instance +func NewBinder(config *rest.Config, opts *BinderOptions) *Binder { + return &Binder{ + config: config, + opts: opts, + } +} + +// TODO: bindFromFile and bindFromResponse can likely share a lot of code. This slow is bit repetitive +// but keeps the two paths separate for clarity. But it needs love. +// https://github.com/kube-bind/kube-bind/issues/360 + +func (b *Binder) BindFromFile(ctx context.Context) ([]*kubebindv1alpha2.APIServiceBinding, error) { + // Ensure client side namespace exists + err := b.ensureClientSideNamespaceExists(ctx) + if err != nil { + return nil, fmt.Errorf("failed to ensure kube-bind namespace exists: %w", err) + } + + remoteKubeconfig, _, _, err := b.getRemoteKubeconfig(ctx, "", "") + if err != nil { + return nil, err + } + + // Copy kubeconfig into local cluster + remoteHost, remoteNamespace, err := base.ParseRemoteKubeconfig([]byte(remoteKubeconfig)) + if err != nil { + return nil, err + } + + kubeClient, err := kubeclient.NewForConfig(b.config) + if err != nil { + return nil, fmt.Errorf("failed to create kube client: %w", err) + } + + secretName, err := base.FindRemoteKubeconfig(ctx, kubeClient, remoteNamespace, remoteHost) + if err != nil { + return nil, err + } + + secret, created, err := base.EnsureKubeconfigSecret(ctx, remoteKubeconfig, secretName, kubeClient) + if err != nil { + return nil, err + } + + if created { + fmt.Fprintf(b.opts.IOStreams.ErrOut, "Created secret %s/%s for host %s, namespace %s\n", "kube-bind", secret.Name, remoteHost, remoteNamespace) + } else { + fmt.Fprintf(b.opts.IOStreams.ErrOut, "Updated secret %s/%s for host %s, namespace %s\n", "kube-bind", secret.Name, remoteHost, remoteNamespace) + } + + if b.opts.DryRun { + return nil, nil + } + + // Get remote kubeconfig + remoteKubeconfig, remoteNamespaceActual, remoteConfig, err := b.getRemoteKubeconfig(ctx, secret.Namespace, secret.Name) + if err != nil { + return nil, fmt.Errorf("failed to get remote kubeconfig: %w", err) + } + + data, err := b.getRequestManifest() + if err != nil { + return nil, fmt.Errorf("failed to get request manifest: %w", err) + } + + request, err := b.unmarshalManifest(data) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal request manifest: %w", err) + } + + // Deploy konnector if needed + if err := b.deployKonnector(ctx); err != nil { + return nil, fmt.Errorf("failed to deploy konnector: %w", err) + } + + // Create bindings for all requests + var bindings []*kubebindv1alpha2.APIServiceBinding + result, err := b.createServiceExportRequest(ctx, remoteConfig, remoteNamespaceActual, request) + if err != nil { + return nil, fmt.Errorf("failed to create service export request: %w", err) + } + + secretName, err = b.createKubeconfigSecret(ctx, remoteConfig.Host, remoteNamespaceActual, remoteKubeconfig) + if err != nil { + return nil, fmt.Errorf("failed to create kubeconfig secret: %w", err) + } + + results, err := b.createAPIServiceBindings(ctx, result, secretName) + if err != nil { + return nil, fmt.Errorf("failed to create API service bindings: %w", err) + } + bindings = append(bindings, results...) + + return bindings, nil +} + +// BindFromResponse processes a BindingResourceResponse and creates all necessary bindings +func (b *Binder) BindFromResponse(ctx context.Context, response *kubebindv1alpha2.BindingResourceResponse) ([]*kubebindv1alpha2.APIServiceBinding, error) { + if response == nil || response.Authentication.OAuth2CodeGrant == nil { + return nil, fmt.Errorf("unexpected response: authentication.oauth2CodeGrant is nil") + } + + // Ensure client side namespace exists + err := b.ensureClientSideNamespaceExists(ctx) + if err != nil { + return nil, fmt.Errorf("failed to ensure kube-bind namespace exists: %w", err) + } + + // Copy kubeconfig into local cluster + remoteHost, remoteNamespace, err := base.ParseRemoteKubeconfig(response.Kubeconfig) + if err != nil { + return nil, err + } + + kubeClient, err := kubeclient.NewForConfig(b.config) + if err != nil { + return nil, fmt.Errorf("failed to create kube client: %w", err) + } + + secretName, err := base.FindRemoteKubeconfig(ctx, kubeClient, remoteNamespace, remoteHost) + if err != nil { + return nil, err + } + + secret, created, err := base.EnsureKubeconfigSecret(ctx, string(response.Kubeconfig), secretName, kubeClient) + if err != nil { + return nil, err + } + + if created { + fmt.Fprintf(b.opts.IOStreams.ErrOut, "Created secret %s/%s for host %s, namespace %s\n", "kube-bind", secret.Name, remoteHost, remoteNamespace) + } else { + fmt.Fprintf(b.opts.IOStreams.ErrOut, "Updated secret %s/%s for host %s, namespace %s\n", "kube-bind", secret.Name, remoteHost, remoteNamespace) + } + + if b.opts.DryRun { + return nil, nil + } + + // Get remote kubeconfig + remoteKubeconfig, remoteNamespaceActual, remoteConfig, err := b.getRemoteKubeconfig(ctx, secret.Namespace, secret.Name) + if err != nil { + return nil, fmt.Errorf("failed to get remote kubeconfig: %w", err) + } + + // Extract the requests + apiRequests := make([]*kubebindv1alpha2.APIServiceExportRequest, len(response.Requests)) + for i, request := range response.Requests { + var meta metav1.TypeMeta + if err := json.Unmarshal(request.Raw, &meta); err != nil { + return nil, fmt.Errorf("unexpected response: failed to unmarshal request #%d: %v", i, err) + } + if got, expected := meta.APIVersion, kubebindv1alpha2.SchemeGroupVersion.String(); got != expected { + return nil, fmt.Errorf("unexpected response: request #%d is not %s, got %s", i, expected, got) + } + var apiRequest kubebindv1alpha2.APIServiceExportRequest + if err := json.Unmarshal(request.Raw, &apiRequest); err != nil { + return nil, fmt.Errorf("failed to unmarshal api request #%d: %v", i+1, err) + } + apiRequests[i] = &apiRequest + } + + // Deploy konnector if needed + if err := b.deployKonnector(ctx); err != nil { + return nil, fmt.Errorf("failed to deploy konnector: %w", err) + } + + // Create bindings for all requests + var bindings []*kubebindv1alpha2.APIServiceBinding + for _, request := range apiRequests { + result, err := b.createServiceExportRequest(ctx, remoteConfig, remoteNamespaceActual, request) + if err != nil { + return nil, fmt.Errorf("failed to create service export request: %w", err) + } + + secretName, err := b.createKubeconfigSecret(ctx, remoteConfig.Host, remoteNamespaceActual, remoteKubeconfig) + if err != nil { + return nil, fmt.Errorf("failed to create kubeconfig secret: %w", err) + } + + results, err := b.createAPIServiceBindings(ctx, result, secretName) + if err != nil { + return nil, fmt.Errorf("failed to create API service bindings: %w", err) + } + bindings = append(bindings, results...) + } + + return bindings, nil +} + +// Helper methods - these delegate to existing methods from BindAPIServiceOptions +func (b *Binder) ensureClientSideNamespaceExists(ctx context.Context) error { + // Create a temporary BindAPIServiceOptions to reuse existing logic + tempOpts := &BindAPIServiceOptions{ + Options: &base.Options{IOStreams: b.opts.IOStreams}, + } + return tempOpts.ensureClientSideNamespaceExists(ctx, b.config) +} + +func (b *Binder) getRemoteKubeconfig(ctx context.Context, namespace, name string) (kubeconfig, ns string, remoteConfig *rest.Config, err error) { + tempOpts := &BindAPIServiceOptions{ + Options: &base.Options{IOStreams: b.opts.IOStreams}, + remoteKubeconfigFile: b.opts.RemoteKubeconfigFile, + remoteKubeconfigNamespace: b.opts.RemoteKubeconfigNamespace, + remoteKubeconfigName: b.opts.RemoteKubeconfigName, + } + return tempOpts.getRemoteKubeconfig(ctx, b.config, namespace, name) +} + +func (b *Binder) deployKonnector(ctx context.Context) error { + tempOpts := &BindAPIServiceOptions{ + Options: &base.Options{IOStreams: b.opts.IOStreams}, + SkipKonnector: b.opts.SkipKonnector, + KonnectorImageOverride: b.opts.KonnectorImageOverride, + DowngradeKonnector: b.opts.DowngradeKonnector, + DryRun: b.opts.DryRun, + } + return tempOpts.deployKonnector(ctx, b.config) +} + +func (b *Binder) createServiceExportRequest(ctx context.Context, remoteConfig *rest.Config, remoteNamespace string, request *kubebindv1alpha2.APIServiceExportRequest) (*kubebindv1alpha2.APIServiceExportRequest, error) { + tempOpts := &BindAPIServiceOptions{ + Options: &base.Options{IOStreams: b.opts.IOStreams}, + remoteNamespace: b.opts.RemoteNamespace, + } + return tempOpts.createServiceExportRequest(ctx, remoteConfig, remoteNamespace, request) +} + +func (b *Binder) createKubeconfigSecret(ctx context.Context, remoteHost, remoteNamespace, remoteKubeconfig string) (string, error) { + tempOpts := &BindAPIServiceOptions{ + Options: &base.Options{IOStreams: b.opts.IOStreams}, + } + + return tempOpts.createKubeconfigSecret(ctx, b.config, remoteHost, remoteNamespace, remoteKubeconfig) +} + +func (b *Binder) createAPIServiceBindings(ctx context.Context, request *kubebindv1alpha2.APIServiceExportRequest, secretName string) ([]*kubebindv1alpha2.APIServiceBinding, error) { + tempOpts := &BindAPIServiceOptions{ + Options: &base.Options{IOStreams: b.opts.IOStreams}, + } + return tempOpts.createAPIServiceBindings(ctx, b.config, request, secretName) +} + +func (b *Binder) getRequestManifest() ([]byte, error) { + if b.opts.File == "-" { + body, err := io.ReadAll(b.opts.IOStreams.In) + if err != nil { + return nil, fmt.Errorf("failed to read from stdin: %w", err) + } + return body, nil + } + + body, err := os.ReadFile(b.opts.File) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", b.opts.File, err) + } + return body, nil +} + +func (b *Binder) unmarshalManifest(bs []byte) (*kubebindv1alpha2.APIServiceExportRequest, error) { + var request kubebindv1alpha2.APIServiceExportRequest + if err := yaml.Unmarshal(bs, &request); err != nil { + return nil, fmt.Errorf("failed to unmarshal manifest: %w", err) + } + if request.APIVersion != kubebindv1alpha2.SchemeGroupVersion.String() { + return nil, fmt.Errorf("invalid apiVersion %q", request.APIVersion) + } + if request.Kind != "APIServiceExportRequest" { + return nil, fmt.Errorf("invalid kind %q", request.Kind) + } + return &request, nil +} diff --git a/cli/pkg/kubectl/bind-apiservice/plugin/konnector.go b/cli/pkg/kubectl/bind-apiservice/plugin/konnector.go index feb5e93d2..f0f122c9f 100644 --- a/cli/pkg/kubectl/bind-apiservice/plugin/konnector.go +++ b/cli/pkg/kubectl/bind-apiservice/plugin/konnector.go @@ -68,7 +68,7 @@ func (b *BindAPIServiceOptions) deployKonnector(ctx context.Context, config *res } if b.KonnectorImageOverride != "" { - fmt.Fprintf(b.Options.ErrOut, "🚀 Deploying konnector %s to namespace kube-bind with custom image %q.\n", bindVersion, b.KonnectorImageOverride) + fmt.Fprintf(b.Options.ErrOut, "Deploying konnector %s to namespace kube-bind with custom image %q.\n", bindVersion, b.KonnectorImageOverride) if err := konnector.Bootstrap(ctx, discoveryClient, dynamicClient, b.KonnectorImageOverride); err != nil { return err } @@ -81,8 +81,8 @@ func (b *BindAPIServiceOptions) deployKonnector(ctx context.Context, config *res konnectorImage := fmt.Sprintf("%s:%s", konnectorImage, bindVersion) if installed { - if konnectorVersion == "unknown" || konnectorVersion == "latest" { - fmt.Fprintf(b.Options.ErrOut, "ℹ️ konnector of %s version already installed, skipping\n", konnectorVersion) + if konnectorVersion == "unknown" || konnectorVersion == "latest" || konnectorVersion == "main" { + fmt.Fprintf(b.Options.ErrOut, "konnector of %s version already installed, skipping\n", konnectorVersion) // fall through to CRD test } else { konnectorSemVer, err := semver.Parse(strings.TrimLeft(konnectorVersion, "v")) @@ -94,16 +94,16 @@ func (b *BindAPIServiceOptions) deployKonnector(ctx context.Context, config *res return fmt.Errorf("failed to parse kubectl-bind SemVer version %q: %w", bindVersion, err) } if bindSemVer.GT(konnectorSemVer) { - fmt.Fprintf(b.Options.ErrOut, "🚀 Updating konnector from %s to %s.\n", konnectorVersion, bindVersion) + fmt.Fprintf(b.Options.ErrOut, "Updating konnector from %s to %s.\n", konnectorVersion, bindVersion) if err := konnector.Bootstrap(ctx, discoveryClient, dynamicClient, konnectorImage); err != nil { return err } } else if bindSemVer.LT(konnectorSemVer) { - fmt.Fprintf(b.Options.ErrOut, "⚠️ Newer konnector %s installed. To downgrade to %s use --downgrade-konnector.\n", konnectorVersion, bindVersion) + fmt.Fprintf(b.Options.ErrOut, "Newer konnector %s installed. To downgrade to %s use --downgrade-konnector.\n", konnectorVersion, bindVersion) } } } else { - fmt.Fprintf(b.Options.ErrOut, "🚀 Deploying konnector %s to namespace kube-bind.\n", bindVersion) + fmt.Fprintf(b.Options.ErrOut, "Deploying konnector %s to namespace kube-bind.\n", bindVersion) if err := konnector.Bootstrap(ctx, discoveryClient, dynamicClient, konnectorImage); err != nil { return err } diff --git a/cli/pkg/kubectl/bind-apiservice/plugin/secret.go b/cli/pkg/kubectl/bind-apiservice/plugin/secret.go index f10823cf1..937725b49 100644 --- a/cli/pkg/kubectl/bind-apiservice/plugin/secret.go +++ b/cli/pkg/kubectl/bind-apiservice/plugin/secret.go @@ -54,7 +54,7 @@ func (b *BindAPIServiceOptions) createKubeconfigSecret(ctx context.Context, conf return secretName, nil } - fmt.Fprintf(b.Options.IOStreams.ErrOut, "🔒 Creating secret for host %s, namespace %s\n", remoteHost, remoteNamespace) + fmt.Fprintf(b.Options.IOStreams.ErrOut, "Creating secret for host %s, namespace %s\n", remoteHost, remoteNamespace) secretName, err = b.ensureKubeconfigSecretWithLogging(ctx, kubeconfig, "", kubeClient) if err != nil { return "", err @@ -76,9 +76,9 @@ func (b *BindAPIServiceOptions) ensureKubeconfigSecretWithLogging(ctx context.Co if b.remoteKubeconfigFile != "" { if created { - fmt.Fprintf(b.Options.ErrOut, "🔒 Created secret %s/%s for host %s, namespace %s\n", "kube-bind", secret.Name, remoteHost, remoteNamespace) + fmt.Fprintf(b.Options.ErrOut, "Created secret %s/%s for host %s, namespace %s\n", "kube-bind", secret.Name, remoteHost, remoteNamespace) } else { - fmt.Fprintf(b.Options.ErrOut, "🔒 Updated secret %s/%s for host %s, namespace %s\n", "kube-bind", secret.Name, remoteHost, remoteNamespace) + fmt.Fprintf(b.Options.ErrOut, "Updated secret %s/%s for host %s, namespace %s\n", "kube-bind", secret.Name, remoteHost, remoteNamespace) } } diff --git a/cli/pkg/kubectl/bind-apiservice/plugin/servicebindings.go b/cli/pkg/kubectl/bind-apiservice/plugin/servicebindings.go index 521f1b5e6..989bae851 100644 --- a/cli/pkg/kubectl/bind-apiservice/plugin/servicebindings.go +++ b/cli/pkg/kubectl/bind-apiservice/plugin/servicebindings.go @@ -54,7 +54,7 @@ func (b *BindAPIServiceOptions) createAPIServiceBindings(ctx context.Context, co if existing.Spec.KubeconfigSecretRef.Namespace != "kube-bind" || existing.Spec.KubeconfigSecretRef.Name != secretName { return nil, fmt.Errorf("found existing APIServiceBinding %s not from this service provider", bindingName) } - fmt.Fprintf(b.Options.IOStreams.ErrOut, "✅ Reusing existing APIServiceBinding %s.\n", existing.Name) + fmt.Fprintf(b.Options.IOStreams.ErrOut, "Reusing existing APIServiceBinding %s.\n", existing.Name) // Validate all CRDs are owned by this binding for _, resource := range request.Spec.Resources { @@ -106,6 +106,6 @@ func (b *BindAPIServiceOptions) createAPIServiceBindings(ctx context.Context, co return nil, err } - fmt.Fprintf(b.Options.IOStreams.ErrOut, "✅ Created APIServiceBinding %s for %d resources\n", bindingName, len(request.Spec.Resources)) + fmt.Fprintf(b.Options.IOStreams.ErrOut, "Created APIServiceBinding %s for %d resources\n", bindingName, len(request.Spec.Resources)) return []*kubebindv1alpha2.APIServiceBinding{created}, nil } diff --git a/cli/pkg/kubectl/bind-collections/cmd/cmd.go b/cli/pkg/kubectl/bind-collections/cmd/cmd.go new file mode 100644 index 000000000..bd73d26d6 --- /dev/null +++ b/cli/pkg/kubectl/bind-collections/cmd/cmd.go @@ -0,0 +1,88 @@ +/* +Copyright 2025 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 cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + logsv1 "k8s.io/component-base/logs/api/v1" + + "github.com/kube-bind/kube-bind/cli/pkg/kubectl/bind-collections/plugin" +) + +var ( + CollectionsExampleUses = ` + # List collections from currently authenticated server + %[1]s collections + + # List collections from specific server + %[1]s collections https://mangodb.com + + # List collections using --server flag to override current server + %[1]s collections --server https://mangodb.com + + # List collections with JSON output + %[1]s collections -o json + ` +) + +func New(streams genericclioptions.IOStreams) (*cobra.Command, error) { + opts := plugin.NewCollectionsOptions(streams) + cmd := &cobra.Command{ + Use: "collections [server-url]", + Short: "List available collections from a kube-bind server", + Long: `List all available collections from a kube-bind server. + +This command connects to a kube-bind server and displays all the collections +that are available. By default, it uses the current authenticated +server from your configuration. + +If you haven't authenticated to any server yet, you must provide a server URL +argument or use 'kubectl bind-login ' first.`, + Example: fmt.Sprintf(CollectionsExampleUses, "kubectl"), + SilenceUsage: true, + Args: func(cmd *cobra.Command, args []string) error { + for _, arg := range args { + if !strings.HasPrefix(arg, "http://") && !strings.HasPrefix(arg, "https://") { + return fmt.Errorf("invalid server URL: %s", arg) + } + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if err := logsv1.ValidateAndApply(opts.Logs, nil); err != nil { + return err + } + + if err := opts.Complete(args); err != nil { + return err + } + + if err := opts.Validate(); err != nil { + return err + } + + return opts.Run(cmd.Context()) + }, + } + opts.AddCmdFlags(cmd) + + return cmd, nil +} diff --git a/cli/pkg/kubectl/bind-collections/plugin/collections.go b/cli/pkg/kubectl/bind-collections/plugin/collections.go new file mode 100644 index 000000000..66602ab7b --- /dev/null +++ b/cli/pkg/kubectl/bind-collections/plugin/collections.go @@ -0,0 +1,141 @@ +/* +Copyright 2025 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 plugin + +import ( + "context" + "fmt" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/util/duration" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/component-base/logs" + logsv1 "k8s.io/component-base/logs/api/v1" + + "github.com/kube-bind/kube-bind/cli/pkg/kubectl/base" + kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" +) + +// CollectionsOptions contains the options for listing collections +type CollectionsOptions struct { + *base.Options + Logs *logs.Options + + Print *genericclioptions.PrintFlags + printer printers.ResourcePrinter +} + +// NewCollectionsOptions creates a new CollectionsOptions +func NewCollectionsOptions(streams genericclioptions.IOStreams) *CollectionsOptions { + return &CollectionsOptions{ + Options: base.NewOptions(streams), + Logs: logs.NewOptions(), + Print: genericclioptions.NewPrintFlags("collections").WithDefaultOutput(""), + } +} + +// AddCmdFlags adds command line flags +func (o *CollectionsOptions) AddCmdFlags(cmd *cobra.Command) { + o.Options.BindFlags(cmd) + logsv1.AddFlags(o.Logs, cmd.Flags()) + o.Print.AddFlags(cmd) +} + +// Complete completes the options +func (o *CollectionsOptions) Complete(args []string) error { + // Set this before complete base settings as login accepts server URL as argument without flag. + if len(args) > 0 { + o.Options.ServerName = args[0] + } + + if err := o.Options.Complete(false); err != nil { + return err + } + + printer, err := o.Print.ToPrinter() + if err != nil { + return err + } + o.printer = printer + + return nil +} + +// Validate validates the options +func (o *CollectionsOptions) Validate() error { + return o.Options.Validate() +} + +// Run executes the collections listing command +func (o *CollectionsOptions) Run(ctx context.Context) error { + // Get authenticated client + client, err := o.Options.GetAuthenticatedClient() + if err != nil { + return fmt.Errorf("failed to create authenticated client: %w", err) + } + + // Fetch collections + collections, err := client.GetCollections(ctx) + if err != nil { + return fmt.Errorf("failed to fetch collections: %w", err) + } + + return o.printCollections(collections) +} + +// printCollections handles printing collections using the configured printer +func (o *CollectionsOptions) printCollections(collections *kubebindv1alpha2.CollectionList) error { + // For non-table output formats, print the whole list + if o.Print.OutputFormat != nil && *o.Print.OutputFormat != "" { + collections.SetGroupVersionKind(kubebindv1alpha2.SchemeGroupVersion.WithKind("CollectionList")) + return o.printer.PrintObj(collections, o.Options.IOStreams.Out) + } + + // For table output, create a custom human-readable table + return o.printCollectionsTable(collections) +} + +// printCollectionsTable prints collections in table format using tabwriter +// TODO: Replace with custom TablePrinter when available in cli-runtime +func (o *CollectionsOptions) printCollectionsTable(collections *kubebindv1alpha2.CollectionList) error { + w := tabwriter.NewWriter(o.Options.IOStreams.Out, 0, 0, 3, ' ', 0) + fmt.Fprintf(w, "NAME\tDESCRIPTION\tTEMPLATES\tAGE\n") + + for _, item := range collections.Items { + description := item.Spec.Description + if description == "" { + description = "" + } + + // Create a comma-separated list of template names + templateNames := make([]string, len(item.Spec.Templates)) + for i, template := range item.Spec.Templates { + templateNames[i] = template.Name + } + templates := strings.Join(templateNames, ",") + + age := duration.HumanDuration(time.Since(item.CreationTimestamp.Time)) + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", item.Name, description, templates, age) + } + + return w.Flush() +} diff --git a/cli/pkg/kubectl/bind-login/cmd/cmd.go b/cli/pkg/kubectl/bind-login/cmd/cmd.go new file mode 100644 index 000000000..568f9392a --- /dev/null +++ b/cli/pkg/kubectl/bind-login/cmd/cmd.go @@ -0,0 +1,80 @@ +/* +Copyright 2025 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 cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + logsv1 "k8s.io/component-base/logs/api/v1" + + "github.com/kube-bind/kube-bind/cli/pkg/help" + "github.com/kube-bind/kube-bind/cli/pkg/kubectl/bind-login/plugin" +) + +var ( + LoginExampleUses = ` + # Login to a kube-bind server + %[1]s login https://my-kube-bind-server.example.com + + # Login with a custom callback port + %[1]s login https://my-kube-bind-server.example.com --callback-port 8081 + + # Login and show authentication status + %[1]s login https://my-kube-bind-server.example.com --show-token + ` +) + +func New(streams genericclioptions.IOStreams) (*cobra.Command, error) { + opts := plugin.NewLoginOptions(streams) + cmd := &cobra.Command{ + Use: "login [SERVER_URL]", + Short: "Login to a kube-bind server and store authentication credentials", + Long: help.Doc(` + Login to a kube-bind server using OAuth2 authentication flow. + + The command will open your browser to complete the OAuth2 flow and + store the resulting JWT token in ~/.kube-bind/config for use by + subsequent commands. + + The SERVER_URL should point to the root of your kube-bind server, + e.g. https://my-server.example.com + `), + Example: fmt.Sprintf(LoginExampleUses, "kubectl bind"), + SilenceUsage: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := logsv1.ValidateAndApply(opts.Logs, nil); err != nil { + return err + } + + if err := opts.Complete(args); err != nil { + return err + } + + if err := opts.Validate(); err != nil { + return err + } + + return opts.Run(cmd.Context(), nil) + }, + } + opts.AddCmdFlags(cmd) + + return cmd, nil +} diff --git a/cli/pkg/kubectl/bind-login/plugin/login.go b/cli/pkg/kubectl/bind-login/plugin/login.go new file mode 100644 index 000000000..582e1dd3b --- /dev/null +++ b/cli/pkg/kubectl/bind-login/plugin/login.go @@ -0,0 +1,374 @@ +/* +Copyright 2025 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 plugin + +import ( + "context" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/component-base/logs" + logsv1 "k8s.io/component-base/logs/api/v1" + + bindconfig "github.com/kube-bind/kube-bind/cli/pkg/config" + "github.com/kube-bind/kube-bind/cli/pkg/kubectl/base" + kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" +) + +// LoginOptions contains the options for the login command +type LoginOptions struct { + *base.Options + Logs *logs.Options + Streams genericclioptions.IOStreams + + // ShowToken displays the stored token after successful authentication + ShowToken bool + + // SkipBrowser skips opening the browser automatically. + SkipBrowser bool + + // Timeout for the authentication flow + Timeout time.Duration + + loginClient *http.Client +} + +// TokenResponse represents the response from the OAuth callback +// Important: this stuct must match one on backend/auth/types.go +type TokenResponse struct { + // OAuth2 token fields + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + Error string `json:"error,omitempty"` + ErrorMessage string `json:"error_description,omitempty"` + + Cluster string `json:"cluster,omitempty"` +} + +// NewLoginOptions creates a new LoginOptions +func NewLoginOptions(streams genericclioptions.IOStreams) *LoginOptions { + opts := base.NewOptions(streams) + return &LoginOptions{ + Options: opts, + Logs: logs.NewOptions(), + Streams: streams, + Timeout: 5 * time.Minute, + } +} + +// AddCmdFlags adds command line flags +func (o *LoginOptions) AddCmdFlags(cmd *cobra.Command) { + o.Options.BindFlags(cmd) + logsv1.AddFlags(o.Logs, cmd.Flags()) + + cmd.Flags().BoolVar(&o.ShowToken, "show-token", false, "Display the stored token after successful authentication") + cmd.Flags().DurationVar(&o.Timeout, "timeout", o.Timeout, "Timeout for the authentication flow") + cmd.Flags().BoolVarP(&o.SkipBrowser, "skip-browser", "", false, "Skip opening the browser automatically") +} + +// Complete completes the options +func (o *LoginOptions) Complete(args []string) error { + if len(args) > 0 { + o.Options.ServerName = strings.TrimSuffix(args[0], "/") + } + err := o.Options.Complete(true) + if err != nil { + return err + } + + o.loginClient = http.DefaultClient + + return nil +} + +// Validate validates the options +func (o *LoginOptions) Validate() error { + return o.Options.Validate() +} + +// Run executes the login command +func (o *LoginOptions) Run(ctx context.Context, authURLCh chan<- string) error { + config := o.Options.GetConfig() + + // Generate a random session ID for cli session to verify callback requests. + sessionID := rand.Text() + + // Setup callback server with random port + tokenCh := make(chan *TokenResponse, 1) + errCh := make(chan error, 1) + + // Get provider information + fmt.Fprintf(o.Streams.ErrOut, "Connecting to kube-bind server %s...\n", o.Options.ServerName) + provider, err := o.getProvider(ctx) + if err != nil { + return fmt.Errorf("failed to get provider information: %w", err) + } + + server, localCallbackURL, err := o.startCallbackServerWithRandomPort(tokenCh, errCh) + if err != nil { + return fmt.Errorf("failed to start callback server: %w", err) + } + defer server.Close() + fmt.Fprintf(o.Streams.ErrOut, "Started local callback server at %s\n", localCallbackURL) + + // Start authentication flow + authURL, err := o.buildAuthURL(provider, localCallbackURL, sessionID) + if err != nil { + return fmt.Errorf("failed to build auth URL: %w", err) + } + if authURLCh != nil { + authURLCh <- authURL + } + + if !o.SkipBrowser { + fmt.Fprintf(o.Streams.ErrOut, "Opening browser for authentication... \n") + err = base.OpenBrowser(authURL) + if err != nil { + fmt.Fprintf(o.Streams.ErrOut, "Failed to open browser automatically: %v\n", err) + fmt.Fprintf(o.Streams.ErrOut, "Please manually open: %s\n\n", authURL) + } + } else { + fmt.Fprintf(o.Streams.ErrOut, "Please open the following URL in your browser to authenticate:\n%s\n\n", authURL) + } + + // Wait for callback with timeout + ctx, cancel := context.WithTimeout(ctx, o.Timeout) + defer cancel() + + var token *TokenResponse + select { + case token = <-tokenCh: + if token.Error != "" { + return fmt.Errorf("authentication failed: %s - %s", token.Error, token.ErrorMessage) + } + case err := <-errCh: + return fmt.Errorf("callback server error: %w", err) + case <-ctx.Done(): + return fmt.Errorf("authentication timed out after %v", o.Timeout) + } + + // Calculate expiration time + if token.ExpiresIn > 0 && token.ExpiresAt.IsZero() { + token.ExpiresAt = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second) + } + + serverHost, err := url.Parse(o.Options.ServerName) + if err != nil { + return fmt.Errorf("failed to parse server URL: %w", err) + } + + // Store token in config + serverConfig := &bindconfig.Server{ + URL: fmt.Sprintf("%s://%s", serverHost.Scheme, serverHost.Host), + Cluster: token.Cluster, + AccessToken: token.AccessToken, + TokenType: token.TokenType, + ExpiresAt: token.ExpiresAt, + } + + // Use cluster-aware server key + serverURL := fmt.Sprintf("%s://%s", serverHost.Scheme, serverHost.Host) + config.AddServerWithCluster(serverURL, token.Cluster, serverConfig) + if err := config.SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + // Set this as the current server using the cluster-aware key + if err := config.SetCurrentServer(serverURL, token.Cluster); err != nil { + return fmt.Errorf("failed to set current server: %w", err) + } + + if err := config.SaveConfig(); err != nil { + return fmt.Errorf("failed to save current server: %w", err) + } + + if token.Cluster != "" { + fmt.Fprintf(o.Streams.ErrOut, "Successfully authenticated to %s (cluster: %s)\n", serverHost.Host, token.Cluster) + fmt.Fprintf(o.Streams.ErrOut, " Server key: %s\n", fmt.Sprintf("%s@%s", serverURL, token.Cluster)) + } else { + fmt.Fprintf(o.Streams.ErrOut, "Successfully authenticated to %s\n", serverHost.Host) + } + + if o.ShowToken { + displayToken := token.AccessToken + if len(displayToken) > 20 { + displayToken = displayToken[:20] + "..." + } + fmt.Fprintf(o.Streams.ErrOut, "\nStored token: %s\n", displayToken) + } + + configPath, _ := config.GetConfigPath() + fmt.Fprintf(o.Streams.ErrOut, "Configuration saved to: %s\n", configPath) + + return nil +} + +func (o *LoginOptions) getProvider(ctx context.Context) (*kubebindv1alpha2.BindingProvider, error) { + url, err := url.Parse(o.Options.ServerName) + if err != nil { + return nil, fmt.Errorf("failed to parse server URL: %w", err) + } + + if !strings.Contains(url.Path, "/api/exports") { + url.Path = "/api/exports" + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := o.loginClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var provider kubebindv1alpha2.BindingProvider + if err := json.Unmarshal(body, &provider); err != nil { + return nil, err + } + + return &provider, nil +} + +func (o *LoginOptions) buildAuthURL(provider *kubebindv1alpha2.BindingProvider, redirectURL, sessionID string) (string, error) { + var oauth2Method *kubebindv1alpha2.OAuth2CodeGrant + for _, m := range provider.AuthenticationMethods { + if m.Method == "OAuth2CodeGrant" { + oauth2Method = m.OAuth2CodeGrant + break + } + } + + if oauth2Method == nil { + return "", fmt.Errorf("server does not support OAuth2 code grant flow") + } + + u, err := url.Parse(oauth2Method.AuthenticatedURL) + if err != nil { + return "", err + } + + values := u.Query() + values.Set("redirect_url", redirectURL) + values.Set("session_id", sessionID) + values.Set("client_type", "cli") + if o.ClusterName != "" { + values.Set("cluster_id", o.ClusterName) + } + u.RawQuery = values.Encode() + + return u.String(), nil +} + +func (o *LoginOptions) startCallbackServerWithRandomPort(tokenCh chan<- *TokenResponse, errCh chan<- error) (*http.Server, string, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, "", fmt.Errorf("failed to find available port: %w", err) + } + + port := listener.Addr().(*net.TCPAddr).Port + listener.Close() + + callbackURL := fmt.Sprintf("http://127.0.0.1:%d/callback", port) + + // Setup HTTP handler + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + token := &TokenResponse{ + Error: r.URL.Query().Get("error"), + ErrorMessage: r.URL.Query().Get("error_description"), + AccessToken: r.URL.Query().Get("access_token"), + TokenType: r.URL.Query().Get("token_type"), + Cluster: r.URL.Query().Get("cluster_id"), + } + + if expiresIn := r.URL.Query().Get("expires_in"); expiresIn != "" { + if exp, err := strconv.ParseInt(expiresIn, 10, 64); err == nil { + token.ExpiresIn = exp + } + } + + // Send success page + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, ` + + + + Kube-Bind Authentication + + + +

Kube-Bind Authentication

+
+

%s

+

You can now close this window and return to the CLI.

+
+ +`, + map[bool]string{true: "success", false: "error"}[token.Error == ""], + map[bool]string{true: "Authentication Successful!", false: "Authentication Failed"}[token.Error == ""]) + + select { + case tokenCh <- token: + default: + } + }) + + server := &http.Server{ + ReadTimeout: time.Minute * 5, + Addr: fmt.Sprintf(":%d", port), + Handler: mux, + } + + go func() { + if err := server.ListenAndServe(); err != http.ErrServerClosed { + select { + case errCh <- err: + default: + } + } + }() + + return server, callbackURL, nil +} diff --git a/cli/pkg/kubectl/bind-templates/cmd/cmd.go b/cli/pkg/kubectl/bind-templates/cmd/cmd.go new file mode 100644 index 000000000..52bc314ad --- /dev/null +++ b/cli/pkg/kubectl/bind-templates/cmd/cmd.go @@ -0,0 +1,88 @@ +/* +Copyright 2025 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 cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + logsv1 "k8s.io/component-base/logs/api/v1" + + "github.com/kube-bind/kube-bind/cli/pkg/kubectl/bind-templates/plugin" +) + +var ( + TemplatesExampleUses = ` + # List templates from currently authenticated server + %[1]s templates + + # List templates from specific server + %[1]s templates https://mangodb.com + + # List templates using --server flag to override current server + %[1]s templates --server https://mangodb.com + + # List templates with JSON output + %[1]s templates -o json + ` +) + +func New(streams genericclioptions.IOStreams) (*cobra.Command, error) { + opts := plugin.NewTemplatesOptions(streams) + cmd := &cobra.Command{ + Use: "templates [server-url]", + Short: "List available exported templates from a kube-bind server", + Long: `List all available exported templates from a kube-bind server. + +This command connects to a kube-bind server and displays all the templates +that are available for binding. By default, it uses the current authenticated +server from your configuration. + +If you haven't authenticated to any server yet, you must provide a server URL +argument or use 'kubectl bind-login ' first.`, + Example: fmt.Sprintf(TemplatesExampleUses, "kubectl"), + SilenceUsage: true, + Args: func(cmd *cobra.Command, args []string) error { + for _, arg := range args { + if !strings.HasPrefix(arg, "http://") && !strings.HasPrefix(arg, "https://") { + return fmt.Errorf("invalid server URL: %s", arg) + } + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if err := logsv1.ValidateAndApply(opts.Logs, nil); err != nil { + return err + } + + if err := opts.Complete(args); err != nil { + return err + } + + if err := opts.Validate(); err != nil { + return err + } + + return opts.Run(cmd.Context()) + }, + } + opts.AddCmdFlags(cmd) + + return cmd, nil +} diff --git a/cli/pkg/kubectl/bind-templates/plugin/templates.go b/cli/pkg/kubectl/bind-templates/plugin/templates.go new file mode 100644 index 000000000..1cb5f1539 --- /dev/null +++ b/cli/pkg/kubectl/bind-templates/plugin/templates.go @@ -0,0 +1,145 @@ +/* +Copyright 2025 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 plugin + +import ( + "context" + "fmt" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/util/duration" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/component-base/logs" + logsv1 "k8s.io/component-base/logs/api/v1" + + "github.com/kube-bind/kube-bind/cli/pkg/kubectl/base" + kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" +) + +// TemplatesOptions contains the options for listing exported templates +type TemplatesOptions struct { + *base.Options + Logs *logs.Options + + Print *genericclioptions.PrintFlags + printer printers.ResourcePrinter +} + +// ExportedResource represents a resource available for binding +type ExportedResource struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Resources []string `json:"resources,omitempty"` +} + +// NewTemplatesOptions creates a new TemplatesOptions +func NewTemplatesOptions(streams genericclioptions.IOStreams) *TemplatesOptions { + return &TemplatesOptions{ + Options: base.NewOptions(streams), + Logs: logs.NewOptions(), + Print: genericclioptions.NewPrintFlags("templates").WithDefaultOutput(""), + } +} + +// AddCmdFlags adds command line flags +func (o *TemplatesOptions) AddCmdFlags(cmd *cobra.Command) { + o.Options.BindFlags(cmd) + logsv1.AddFlags(o.Logs, cmd.Flags()) + o.Print.AddFlags(cmd) +} + +// Complete completes the options +func (o *TemplatesOptions) Complete(args []string) error { + if err := o.Options.Complete(false); err != nil { + return err + } + printer, err := o.Print.ToPrinter() + if err != nil { + return err + } + o.printer = printer + + return nil +} + +// Validate validates the options +func (o *TemplatesOptions) Validate() error { + return nil +} + +// Run executes the templates listing command +func (o *TemplatesOptions) Run(ctx context.Context) error { + // Get authenticated client + client, err := o.Options.GetAuthenticatedClient() + if err != nil { + return fmt.Errorf("failed to create authenticated client: %w", err) + } + + // Fetch exported templates + templates, err := client.GetTemplates(ctx) + if err != nil { + return fmt.Errorf("failed to fetch exported templates: %w", err) + } + + // Display templates + return o.displayTemplates(templates) +} + +// displayTemplates displays the templates using the configured printer +func (o *TemplatesOptions) displayTemplates(templates *kubebindv1alpha2.APIServiceExportTemplateList) error { + // For non-table output formats, print the whole list + if o.Print.OutputFormat != nil && *o.Print.OutputFormat != "" { + templates.SetGroupVersionKind(kubebindv1alpha2.SchemeGroupVersion.WithKind("APIServiceExportTemplateList")) + return o.printer.PrintObj(templates, o.IOStreams.Out) + } + + // For table output, create a custom human-readable table + return o.displayTemplatesTable(templates) +} + +// displayTemplatesTable prints templates in table format using tabwriter +// TODO: Replace with custom TablePrinter when available in cli-runtime +func (o *TemplatesOptions) displayTemplatesTable(templates *kubebindv1alpha2.APIServiceExportTemplateList) error { + w := tabwriter.NewWriter(o.Options.IOStreams.Out, 0, 0, 3, ' ', 0) + fmt.Fprintf(w, "NAME\tRESOURCES\tPERMISSIONCLAIMS\tAGE\n") + + for _, item := range templates.Items { + // Create a comma-separated list of resource groups + resourceGroups := make([]string, len(item.Spec.Resources)) + for i, resource := range item.Spec.Resources { + resourceGroups[i] = resource.Group + } + resources := strings.Join(resourceGroups, ",") + + // Create a comma-separated list of permission claim resources + permissionResources := make([]string, len(item.Spec.PermissionClaims)) + for i, claim := range item.Spec.PermissionClaims { + permissionResources[i] = claim.Resource + } + permissionClaims := strings.Join(permissionResources, ",") + + age := duration.HumanDuration(time.Since(item.CreationTimestamp.Time)) + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", item.Name, resources, permissionClaims, age) + } + + return w.Flush() +} diff --git a/cli/pkg/kubectl/bind/cmd/cmd.go b/cli/pkg/kubectl/bind/cmd/cmd.go index 0d1dd8fcd..46e9cdd39 100644 --- a/cli/pkg/kubectl/bind/cmd/cmd.go +++ b/cli/pkg/kubectl/bind/cmd/cmd.go @@ -33,42 +33,53 @@ import ( ) var ( - // TODO: add other examples related to permission claim commands. BindExampleUses = ` - # select a kube-bind.io compatible service from the given URL, e.g. an API service. - %[1]s bind https://mangodb.com/exports + # Open kube-bind UI for current server context + %[1]s bind - # authenticate and configure the services to bind, but don't actually bind them. - %[1]s bind https://mangodb.com/exports --dry-run -o yaml > apiservice-export-requests.yaml + # Open kube-bind UI for specific server + %[1]s bind https://mangodb.com - # bind to a remote API service as configured above and actually bind to it, e.g. in GitOps automation. - %[1]s bind apiservice --remote-kubeconfig name -f apiservice-binding-requests.yaml + # List available templates (CLI mode) + %[1]s bind --dry-run - # bind to a remote API service via a request manifest from a https URL. - %[1]s bind apiservice --remote-kubeconfig name https://some-url.com/apiservice-export-requests.yaml + # Bind specific template (CLI mode) + %[1]s bind --template my-app + + # Bind template with server override + %[1]s bind https://mangodb.com --template my-app + + # View template details without binding + %[1]s bind --template my-app --dry-run ` ) func New(streams genericclioptions.IOStreams) (*cobra.Command, error) { opts := plugin.NewBindOptions(streams) cmd := &cobra.Command{ - Use: "bind", - Short: "kubectl plugin for kube-bind, bind different remote types into the current cluster.", + Use: "bind [server-url]", + Short: "Open kube-bind UI or bind templates from a remote server.", Long: help.Doc(` - kube-bind is a project that aims to provide better support for - service providers and consumers that reside in distinct Kubernetes clusters. + kube-bind allows you to bind remote services into your cluster using either + a web UI or command-line interface. - For more information, see: https://kube-bind.io + By default, 'kubectl bind' opens the kube-bind web UI in your browser. + Use the --template flag to bind specific templates via CLI. - To bind a remote service, use the 'kubectl bind' command. - Please check the examples below for more information. + For more information, see: https://kube-bind.io `), Example: fmt.Sprintf(BindExampleUses, "kubectl"), SilenceUsage: true, Args: func(cmd *cobra.Command, args []string) error { - for _, arg := range args { + // Allow 0 or 1 arguments + if len(args) > 1 { + return fmt.Errorf("too many arguments, expected at most 1") + } + // If argument is provided, it must be a URL + if len(args) == 1 { + arg := args[0] if !strings.HasPrefix(arg, "http://") && !strings.HasPrefix(arg, "https://") { - return fmt.Errorf("unknown argument: %s", arg) // this will fall back to sub-commands + return fmt.Errorf("server URL must start with http:// or https://, got: %s", arg) } } return nil @@ -81,9 +92,6 @@ func New(streams genericclioptions.IOStreams) (*cobra.Command, error) { yellow := color.New(color.BgRed, color.FgBlack).SprintFunc() fmt.Fprintf(streams.ErrOut, "%s\n\n", yellow("DISCLAIMER: This is a prototype. It will change in incompatible ways at any time.")) - if len(args) == 0 { - return cmd.Help() - } if err := opts.Complete(args); err != nil { return err } diff --git a/cli/pkg/kubectl/bind/plugin/authenticate.go b/cli/pkg/kubectl/bind/plugin/authenticate.go index 3b9ce644a..9311eded1 100644 --- a/cli/pkg/kubectl/bind/plugin/authenticate.go +++ b/cli/pkg/kubectl/bind/plugin/authenticate.go @@ -17,71 +17,12 @@ limitations under the License. package plugin import ( - "crypto/tls" - "encoding/json" - "errors" "fmt" - "io" - "net" - "net/http" - "net/url" "strings" "github.com/blang/semver/v4" - "github.com/mdp/qrterminal/v3" - clientgoversion "k8s.io/client-go/pkg/version" - - "github.com/kube-bind/kube-bind/pkg/version" - kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" ) -func getProvider(url string, insecure bool) (*kubebindv1alpha2.BindingProvider, error) { - client := &http.Client{} - if insecure { - client.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: insecure, //nolint:gosec - }, - } - } - - resp, err := client.Get(url) //nolint:noctx - if err != nil { - return nil, err - } - - blob, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - if err := resp.Body.Close(); err != nil { - return nil, err - } - - provider := &kubebindv1alpha2.BindingProvider{} - if err := json.Unmarshal(blob, provider); err != nil { - return nil, err - } - - // check provider version compatibility - bindVersion, err := version.BinaryVersion(clientgoversion.Get().GitVersion) - if err != nil { - return nil, err - } - if bindSemVer, err := semver.Parse(strings.TrimLeft(bindVersion, "v")); err != nil { - return nil, fmt.Errorf("failed to parse bind version %q: %v", bindVersion, err) - } else if min := semver.MustParse("0.5.0"); bindSemVer.GE(min) { - // At v0.5.0 we made breaking change for how APIExports looks like. - // So we need to test for v0.5.0+. If - if err := validateProviderVersion(provider.Version); err != nil { - return nil, err - } - } - - return provider, nil -} - func validateProviderVersion(providerVersion string) error { switch providerVersion { case "": @@ -96,62 +37,9 @@ func validateProviderVersion(providerVersion string) error { return fmt.Errorf("provider version %q cannot be parsed", providerVersion) } // Check if provider is higher than 0.4.8, we need to have same version of kube-bind to use this provider. - if min := semver.MustParse("0.5.0"); providerSemVer.LT(min) { + if min := semver.MustParse("0.6.0"); providerSemVer.LT(min) { return fmt.Errorf("provider version %s is not supported, must be at least v%s", providerVersion, min) } return nil } - -func (b *BindOptions) authenticate(provider *kubebindv1alpha2.BindingProvider, callback, sessionID, clusterID string, urlCh chan<- string) error { - var oauth2Method *kubebindv1alpha2.OAuth2CodeGrant - for _, m := range provider.AuthenticationMethods { - if m.Method == "OAuth2CodeGrant" { - oauth2Method = m.OAuth2CodeGrant - break - } - } - if oauth2Method == nil { - return errors.New("server does not support OAuth2 code grant flow") - } - - u, err := url.Parse(oauth2Method.AuthenticatedURL) - if err != nil { - return fmt.Errorf("failed to parse auth url: %v", err) - } - - cbURL, err := url.Parse(callback) - if err != nil { - return fmt.Errorf("failed to parse callback url: %v", err) - } - _, cbPort, err := net.SplitHostPort(cbURL.Host) - if err != nil { - return fmt.Errorf("failed to parse callback port: %v", err) - } - - values := u.Query() - values.Add("p", cbPort) - values.Add("s", sessionID) - values.Add("c", clusterID) - u.RawQuery = values.Encode() - - fmt.Fprintf(b.Options.ErrOut, "\nTo authenticate, visit in your browser:\n\n\t%s\n", u.String()) - - // TODO(sttts): callback backend, not 127.0.0.1 - if false { - fmt.Fprintf(b.Options.ErrOut, "\n\nor scan the QRCode below:") - config := qrterminal.Config{ - Level: qrterminal.L, - Writer: b.Options.ErrOut, - BlackChar: qrterminal.WHITE, - WhiteChar: qrterminal.BLACK, - QuietZone: 2, - } - qrterminal.GenerateWithConfig(u.String(), config) - } - if urlCh != nil { - urlCh <- u.String() - } - - return nil -} diff --git a/cli/pkg/kubectl/bind/plugin/bind.go b/cli/pkg/kubectl/bind/plugin/bind.go index 1e62d0055..c271141cd 100644 --- a/cli/pkg/kubectl/bind/plugin/bind.go +++ b/cli/pkg/kubectl/bind/plugin/bind.go @@ -17,36 +17,41 @@ limitations under the License. package plugin import ( - "bytes" "context" "crypto/rand" - "crypto/sha256" + "encoding/base64" "encoding/json" - "errors" "fmt" - "math/big" + "net" + "net/http" "net/url" - "os" "os/exec" - "strings" + "strconv" "time" "github.com/spf13/cobra" "github.com/spf13/pflag" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/cli-runtime/pkg/printers" - kubeclient "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" "k8s.io/component-base/logs" logsv1 "k8s.io/component-base/logs/api/v1" "github.com/kube-bind/kube-bind/cli/pkg/kubectl/base" - "github.com/kube-bind/kube-bind/cli/pkg/kubectl/bind/authenticator" + bindapiservice "github.com/kube-bind/kube-bind/cli/pkg/kubectl/bind-apiservice/plugin" kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" ) +// BindResult represents the result from the UI callback +type BindResult struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + ErrorDescription string `json:"error_description,omitempty"` + + Response kubebindv1alpha2.BindingResourceResponse `json:"response,omitempty"` +} + // BindOptions contains the options for creating an APIBinding. type BindOptions struct { *base.Options @@ -56,13 +61,6 @@ type BindOptions struct { printer printers.ResourcePrinter DryRun bool - // skipInsecure skips the verification of the server's certificate chain and host name. - SkipInsecure bool - - // url is the argument accepted by the command. It contains the - // reference to where an APIService exists. - URL string - // skipKonnector skips the deployment of the konnector. SkipKonnector bool @@ -100,20 +98,16 @@ func (b *BindOptions) AddCmdFlags(cmd *cobra.Command) { cmd.Flags().BoolVar(&b.SkipKonnector, "skip-konnector", b.SkipKonnector, "Skip the deployment of the konnector") cmd.Flags().BoolVarP(&b.DryRun, "dry-run", "d", b.DryRun, "If true, only print the requests that would be sent to the service provider after authentication, without actually binding.") - cmd.Flags().BoolVar(&b.SkipInsecure, "insecure-skip-tls-verify", b.SkipInsecure, "Skip the verification of the server's certificate chain and host name.") cmd.Flags().StringVar(&b.KonnectorImageOverride, "konnector-image", b.KonnectorImageOverride, "The konnector image to use") } // Complete ensures all fields are initialized. func (b *BindOptions) Complete(args []string) error { - if err := b.Options.Complete(); err != nil { + // Try base completion, but don't fail if no current server is configured + if err := b.Options.Complete(false); err != nil { return err } - if len(args) > 0 { - b.URL = args[0] - } - printer, err := b.Print.ToPrinter() if err != nil { return err @@ -126,12 +120,12 @@ func (b *BindOptions) Complete(args []string) error { // Validate validates the BindOptions are complete and usable. func (b *BindOptions) Validate() error { - if b.URL == "" { - return errors.New("url is required as an argument") // should not happen because we validate that before + if b.ServerName == "" { + return fmt.Errorf("server is required") } - if _, err := url.Parse(b.URL); err != nil { - return fmt.Errorf("invalid url %q: %w", b.URL, err) + if _, err := url.Parse(b.ServerName); err != nil { + return fmt.Errorf("invalid url %q: %w", b.ServerName, err) } return b.Options.Validate() @@ -139,191 +133,252 @@ func (b *BindOptions) Validate() error { // Run starts the binding process. func (b *BindOptions) Run(ctx context.Context, urlCh chan<- string) error { - config, err := b.ClientConfig.ClientConfig() - if err != nil { - return err - } - kubeClient, err := kubeclient.NewForConfig(config) - if err != nil { - return err - } + // Always use UI mode with callback listener + return b.runWithCallback(ctx, urlCh) +} - exportURL, err := url.Parse(b.URL) +// runWithCallback creates a local callback listener and opens the UI +func (b *BindOptions) runWithCallback(ctx context.Context, _ chan<- string) error { + _, err := b.Options.ClientConfig.ClientConfig() if err != nil { - return err // should never happen because we test this in Validate() + return fmt.Errorf("failed to get client config: %w", err) } - provider, err := getProvider(exportURL.String(), b.SkipInsecure) - if err != nil { - return fmt.Errorf("failed to fetch authentication url %q: %v", exportURL, err) - } + // Generate session ID. It is used to verify callback. + sessionID := rand.Text() - if provider.APIVersion != kubebindv1alpha2.GroupVersion { - return fmt.Errorf("unsupported binding provider version %q, expected %q", provider.APIVersion, kubebindv1alpha2.GroupVersion) - } + // Setup callback server with random port + resultCh := make(chan *BindResult, 1) + errCh := make(chan error, 1) - ns, err := kubeClient.CoreV1().Namespaces().Get(ctx, "kube-bind", metav1.GetOptions{}) - if err != nil && !apierrors.IsNotFound(err) { - return err - } else if apierrors.IsNotFound(err) { - ns = &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "kube-bind", - }, - } - if ns, err = kubeClient.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}); err != nil { - return err - } else { - fmt.Fprintf(b.Options.IOStreams.ErrOut, "📦 Created kube-bind namespace.\n") - } + callbackServer, callbackPort, err := b.startCallbackServer(resultCh, errCh, sessionID) + if err != nil { + return fmt.Errorf("failed to start callback server: %w", err) } + defer callbackServer.Close() - auth := authenticator.NewLocalhostCallbackAuthenticator() - err = auth.Start() - fmt.Fprintf(b.Options.ErrOut, "\n\n") + // Build the UI URL with callback parameters + uiURL, err := b.buildUIURL(callbackPort, sessionID, b.ClusterName) if err != nil { - return err + return fmt.Errorf("failed to build UI URL: %w", err) } - sessionID := SessionID() - if err := b.authenticate(provider, auth.Endpoint(), sessionID, ClusterID(ns), urlCh); err != nil { - return err + fmt.Fprintf(b.Options.IOStreams.ErrOut, "🌐 Opening kube-bind UI in your browser...\n") + fmt.Fprintf(b.Options.IOStreams.ErrOut, " %s\n\n", uiURL) + + // Open browser + if err := base.OpenBrowser(uiURL); err != nil { + fmt.Fprintf(b.Options.IOStreams.ErrOut, "Failed to open browser automatically: %v\n", err) + fmt.Fprintf(b.Options.IOStreams.ErrOut, "Please manually open: %s\n\n", uiURL) + } else { + fmt.Fprintf(b.Options.IOStreams.ErrOut, "Browser opened successfully\n") } - timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) + fmt.Fprintf(b.Options.IOStreams.ErrOut, "Waiting for binding completion from UI...\n") + fmt.Fprintf(b.Options.IOStreams.ErrOut, " (Press Ctrl+C to cancel)\n\n") + + // Wait for callback result with context cancellation + ctx, cancel := context.WithCancel(ctx) defer cancel() - response, gvk, err := auth.WaitForResponse(timeoutCtx) - if err != nil { - return err - } - fmt.Fprintf(b.IOStreams.ErrOut, "🔑 Successfully authenticated to %s\n", exportURL.String()) + select { + case result := <-resultCh: + if result.Error != "" { + return fmt.Errorf("binding failed: %s - %s", result.Error, result.ErrorDescription) + } - // verify the response - if gvk.GroupVersion() != kubebindv1alpha2.SchemeGroupVersion || gvk.Kind != "BindingResponse" { - return fmt.Errorf("unexpected response type %s, only supporting %s", gvk, kubebindv1alpha2.SchemeGroupVersion.WithKind("BindingResponse")) - } - bindingResponse, ok := response.(*kubebindv1alpha2.BindingResponse) - if !ok { - return fmt.Errorf("unexpected response type %T", response) - } - if bindingResponse.Authentication.OAuth2CodeGrant == nil { - return fmt.Errorf("unexpected response: authentication.oauth2CodeGrant is nil") - } - if bindingResponse.Authentication.OAuth2CodeGrant.SessionID != sessionID { - return fmt.Errorf("unexpected response: sessionID does not match") - } + fmt.Fprintf(b.Options.IOStreams.ErrOut, "Binding completed successfully!\n") + if result.Message != "" { + fmt.Fprintf(b.Options.IOStreams.ErrOut, " %s\n", result.Message) + } - // extract the requests - apiRequests := make([]*kubebindv1alpha2.APIServiceExportRequestResponse, len(bindingResponse.Requests)) - for i, request := range bindingResponse.Requests { - var meta metav1.TypeMeta - if err := json.Unmarshal(request.Raw, &meta); err != nil { - return fmt.Errorf("unexpected response: failed to unmarshal request #%d: %v", i, err) + // Handle dry-run mode + if b.DryRun { + fmt.Fprintf(b.Options.IOStreams.ErrOut, "Dry-run mode: outputting APIServiceExport requests\n\n") + + // Print each request from the response using the configured printer + for i, request := range result.Response.Requests { + if i > 0 && b.Print.OutputFormat != nil && *b.Print.OutputFormat == "yaml" { + fmt.Fprintf(b.Options.IOStreams.Out, "---\n") + } + + // TODO: support proper k/k style printers. + // Unmarshal the raw JSON into an APIServiceExportRequest + var apiRequest kubebindv1alpha2.APIServiceExportRequest + if err := json.Unmarshal(request.Raw, &apiRequest); err != nil { + return fmt.Errorf("failed to unmarshal request %d: %w", i, err) + } + + // Use the printer to output in the requested format + if b.printer != nil { + if err := b.printer.PrintObj(&apiRequest, b.Options.IOStreams.Out); err != nil { + return fmt.Errorf("failed to print request %d: %w", i, err) + } + } else { + // Fallback to raw JSON output if printer is not available + fmt.Fprintf(b.Options.IOStreams.Out, "%s", request.Raw) + } + } } - if got, expected := meta.APIVersion, kubebindv1alpha2.SchemeGroupVersion.String(); got != expected { - return fmt.Errorf("unexpected response: request #%d is not %s, got %s", i, expected, got) + + // Create bindings using the shared binder + config, err := b.Options.ClientConfig.ClientConfig() + if err != nil { + return fmt.Errorf("failed to get client config: %w", err) } - var apiRequest kubebindv1alpha2.APIServiceExportRequestResponse - if err := json.Unmarshal(request.Raw, &apiRequest); err != nil { - return fmt.Errorf("failed to unmarshal api request #%d: %v", i+1, err) + + bindings, err := b.bindResponseToAPIServiceBindings(ctx, config, &result.Response) + if err != nil { + return fmt.Errorf("failed to create APIServiceBindings: %w", err) } - apiRequests[i] = &apiRequest - } - // copy kubeconfig into local cluster - remoteHost, remoteNamespace, err := base.ParseRemoteKubeconfig(bindingResponse.Kubeconfig) - if err != nil { - return err - } - secretName, err := base.FindRemoteKubeconfig(ctx, kubeClient, remoteNamespace, remoteHost) - if err != nil { - return err - } - secret, created, err := base.EnsureKubeconfigSecret(ctx, string(bindingResponse.Kubeconfig), secretName, kubeClient) - if err != nil { - return err - } - if created { - fmt.Fprintf(b.Options.ErrOut, "🔒 Created secret %s/%s for host %s, namespace %s\n", "kube-bind", secret.Name, remoteHost, remoteNamespace) - } else { - fmt.Fprintf(b.Options.ErrOut, "🔒 Updated secret %s/%s for host %s, namespace %s\n", "kube-bind", secret.Name, remoteHost, remoteNamespace) - } + if b.DryRun { + fmt.Fprintf(b.Options.IOStreams.ErrOut, "\nDry-run mode: no APIServiceBindings were created.\n") + return nil + } - // print the request in dry-run mode - if b.DryRun { - for _, request := range apiRequests { - if err = b.printer.PrintObj(request, b.IOStreams.Out); err != nil { - return err + // Print the results + if len(bindings) > 0 { + fmt.Fprintf(b.Options.IOStreams.ErrOut, "Created %d APIServiceBinding(s):\n", len(bindings)) + for _, binding := range bindings { + fmt.Fprintf(b.Options.IOStreams.Out, " - %s\n", binding.Name) } } - } - if b.DryRun { + fmt.Fprintf(b.Options.IOStreams.ErrOut, "Resources bound successfully!\n") return nil + + case err := <-errCh: + return fmt.Errorf("callback server error: %w", err) + case <-ctx.Done(): + return fmt.Errorf("operation cancelled") } +} - // call sub-command for apiservices - executable, err := os.Executable() +// buildUIURL constructs the UI URL with callback parameters +func (b *BindOptions) buildUIURL(callbackPort int, sessionID, clusterID string) (string, error) { + // Parse the base URL + u, err := url.Parse(b.ServerName) if err != nil { - return err + return "", fmt.Errorf("invalid server URL: %w", err) } - for _, request := range apiRequests { - bs, err := json.Marshal(request) - if err != nil { - return err - } - args := []string{ - "apiservice", - "--remote-kubeconfig-namespace", secret.Namespace, - "--remote-kubeconfig-name", secret.Name, - "-f", "-", - } - b.flags.VisitAll(func(flag *pflag.Flag) { - if flag.Changed && PassOnFlags.Has(flag.Name) { - args = append(args, "--"+flag.Name+"="+flag.Value.String()) - } - }) + redirectURL := "http://127.0.0.1:" + strconv.Itoa(callbackPort) + "/callback" + + // Add query parameters + values := u.Query() + values.Add("session_id", sessionID) + values.Add("redirect_url", redirectURL) + if clusterID != "" { + values.Add("cluster_id", clusterID) + } + u.RawQuery = values.Encode() + + return u.String(), nil +} - if b.KonnectorImageOverride != "" { - args = append(args, "--konnector-image"+"="+b.KonnectorImageOverride) +// startCallbackServer starts a local HTTP server to receive the callback from the UI +func (b *BindOptions) startCallbackServer(resultCh chan<- *BindResult, errCh chan<- error, sessionID string) (*http.Server, int, error) { + // Find an available port + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, 0, fmt.Errorf("failed to find available port: %w", err) + } + + port := listener.Addr().(*net.TCPAddr).Port + listener.Close() + + // Setup HTTP handler + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + // Validate session ID + if r.URL.Query().Get("session_id") != sessionID { + http.Error(w, "Invalid session", http.StatusUnauthorized) + return } - // TODO: support passing through the base options + result := &BindResult{} + payload := kubebindv1alpha2.BindingResourceResponse{} - fmt.Fprintf(b.Options.ErrOut, "\n") - fmt.Fprintf(b.Options.ErrOut, "🚀 Executing: %s %s\n", "kubectl bind", strings.Join(args, " ")) - fmt.Fprintf(b.Options.ErrOut, "✨ Use \"-o yaml\" and \"--dry-run\" to get the APIServiceExportRequest.\n and pass it to \"kubectl bind apiservice\" directly. Great for automation.\n") + // Parse query parameters (for simple callbacks) + query := r.URL.Query() - command := exec.CommandContext(ctx, executable, append(args, "--no-banner")...) - command.Stdin = bytes.NewReader(bs) - command.Stdout = b.Options.Out - command.Stderr = b.Options.ErrOut - if err := b.Runner(command); err != nil { - return err + response := query.Get("binding_response") + responseData, err := base64.URLEncoding.DecodeString(response) + if err != nil { + http.Error(w, "Invalid binding_response", http.StatusBadRequest) + return } + if err := json.Unmarshal(responseData, &payload); err != nil { + http.Error(w, "Invalid binding_response JSON", http.StatusBadRequest) + return + } + + result.Response = payload + + result.Success = query.Get("success") == "true" + result.Message = query.Get("message") + result.Error = query.Get("error") + result.ErrorDescription = query.Get("error_description") + + // Send success page + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, ` + + + + Kube-Bind - Binding Complete + + + +

Kube-Bind

+
+

%s

+

You can now close this window and return to the CLI.

+
+ +`, + map[bool]string{true: "success", false: "error"}[result.Success || result.Error == ""], + map[bool]string{true: "Binding Completed Successfully!", false: "Binding Failed"}[result.Success || result.Error == ""]) + + // Send result to channel + select { + case resultCh <- result: + default: + } + }) + + server := &http.Server{ + ReadTimeout: time.Minute * 5, + Addr: fmt.Sprintf(":%d", port), + Handler: mux, } - return nil -} + go func() { + if err := server.ListenAndServe(); err != http.ErrServerClosed { + select { + case errCh <- err: + default: + } + } + }() -func ClusterID(ns *corev1.Namespace) string { - hash := sha256.Sum224([]byte(ns.UID)) - base62hash := toBase62(hash) - return base62hash[:6] // 50 billion + return server, port, nil } -func SessionID() string { - var b [28]byte - if _, err := rand.Read(b[:]); err != nil { - panic(err) +// bindResponseToAPIServiceBindings uses the shared binder to create API service bindings +func (b *BindOptions) bindResponseToAPIServiceBindings(ctx context.Context, config *rest.Config, response *kubebindv1alpha2.BindingResourceResponse) ([]*kubebindv1alpha2.APIServiceBinding, error) { + binderOpts := &bindapiservice.BinderOptions{ + IOStreams: b.Options.IOStreams, + SkipKonnector: b.SkipKonnector, + KonnectorImageOverride: b.KonnectorImageOverride, + DryRun: b.DryRun, } - return toBase62(b)[:6] // 50 billion -} -func toBase62(hash [28]byte) string { - var i big.Int - i.SetBytes(hash[:]) - return i.Text(62) + binder := bindapiservice.NewBinder(config, binderOpts) + return binder.BindFromResponse(ctx, response) } diff --git a/cli/pkg/kubectl/bind/plugin/flags.go b/cli/pkg/kubectl/bind/plugin/flags.go deleted file mode 100644 index 4e41adb23..000000000 --- a/cli/pkg/kubectl/bind/plugin/flags.go +++ /dev/null @@ -1,48 +0,0 @@ -/* -Copyright 2022 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 plugin - -import ( - "k8s.io/apimachinery/pkg/util/sets" -) - -var ( - // passOnFlags are the flags we pass to downstream commands like kubectl-bind-apiservice. - PassOnFlags = sets.NewString( - "allow-missing-template-keys", - "kubeconfig", - "log-flush-frequency", - "log-text-info-buffer-size", - "log-text-split-stream", - "logging-format", - "o", - "output", - "show-managed-fields", - "skip-konnector", - "template", - "v", - "vmodule", - "konnector-image", - "insecure-skip-tls-verify", - ) - - // passOnEnvVars are the flags we DO NOT pass to downstream commands like kubectl-bind-apiservice. - LocalFlags = sets.NewString( - "d", - "dry-run", - ) -) diff --git a/cli/pkg/kubectl/bind/plugin/flags_test.go b/cli/pkg/kubectl/bind/plugin/flags_test.go deleted file mode 100644 index 1da10794d..000000000 --- a/cli/pkg/kubectl/bind/plugin/flags_test.go +++ /dev/null @@ -1,51 +0,0 @@ -/* -Copyright 2022 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 plugin - -import ( - "fmt" - "testing" - - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "github.com/stretchr/testify/require" - "k8s.io/apimachinery/pkg/util/sets" - "k8s.io/cli-runtime/pkg/genericclioptions" -) - -func TestFlags(t *testing.T) { - cmd := cobra.Command{} - opts := NewBindOptions(genericclioptions.IOStreams{}) - opts.AddCmdFlags(&cmd) - - all := sets.NewString() - cmd.Flags().VisitAll(func(flag *pflag.Flag) { - all.Insert(flag.Name) - if flag.Shorthand != "" { - all.Insert(flag.Shorthand) - } - if flag.ShorthandDeprecated != "" { - all.Insert(flag.ShorthandDeprecated) - } - }) - - missing := all.Difference(PassOnFlags).Difference(LocalFlags) - for _, flag := range missing.List() { - fmt.Printf("%q,\n", flag) - } - require.Empty(t, missing.List()) -} diff --git a/contrib/kcp/README.md b/contrib/kcp/README.md index d230716b6..a91254924 100644 --- a/contrib/kcp/README.md +++ b/contrib/kcp/README.md @@ -55,7 +55,7 @@ k ws use :root:kube-bind --oidc-issuer-client-secret=ZXhhbXBsZS1hcHAtc2VjcmV0 \ --oidc-issuer-client-id=kube-bind \ --oidc-issuer-url=http://127.0.0.1:5556/dex \ - --oidc-callback-url=http://127.0.0.1:8080/callback \ + --oidc-callback-url=http://127.0.0.1:8080/api/callback \ --pretty-name="BigCorp.com" \ --namespace-prefix="kube-bind-" \ --cookie-signing-key=bGMHz7SR9XcI9JdDB68VmjQErrjbrAR9JdVqjAOKHzE= \ @@ -108,7 +108,7 @@ kubectl apply -f contrib/kcp/deploy/examples/collection-wildwest.yaml ```bash kubectl get logicalcluster # NAME PHASE URL AGE -# cluster Ready https://192.168.2.166:6443/clusters/2cc89nxsuivawooq +# cluster Ready https://192.168.2.166:6443/clusters/1y7tqtsucxaekmqu ``` ## Consumer @@ -124,7 +124,8 @@ kubectl ws create consumer --enter 10. Bind the thing: ```bash -./bin/kubectl-bind http://127.0.0.1:8080/clusters/2cc89nxsuivawooq/exports --dry-run -o yaml > apiserviceexport.yaml +./bin/kubectl-bind login http://127.0.0.1:8080 --cluster 7yw1thtocnvdhf74 +./bin/kubectl-bind --dry-run -o yaml > apiserviceexport.yaml # Extract secret for binding process. Note that secret name is not the same as output from command above. Check secret # name by running `kubectl get secret -n kube-bind` @@ -132,7 +133,7 @@ kubectl get secrets -n kube-bind -o jsonpath='{.items[0].data.kubeconfig}' | bas namespace=$(yq '.contexts[0].context.namespace' remote.kubeconfig) -./bin/kubectl-bind apiservice -v 6 --remote-kubeconfig remote.kubeconfig -f apiserviceexport.yaml --skip-konnector --remote-namespace "$namespace" +./bin/kubectl-bind apiservice -v 6 --remote-kubeconfig remote.kubeconfig -f apiserviceexport.yaml --skip-konnector --remote-namespace "$namespace" ``` This will keep running, so switch to a new terminal. diff --git a/contrib/kcp/deploy/bootstrap.go b/contrib/kcp/deploy/bootstrap.go index 7ef602a0b..8d270d7db 100644 --- a/contrib/kcp/deploy/bootstrap.go +++ b/contrib/kcp/deploy/bootstrap.go @@ -18,6 +18,7 @@ package kubebind import ( "context" + "embed" "time" kcpapiextensionsclientset "github.com/kcp-dev/client-go/apiextensions/client" @@ -35,6 +36,9 @@ import ( "github.com/kube-bind/kube-bind/contrib/kcp/bootstrap/config/kcp/resources" ) +//go:embed examples/*.yaml +var Examples embed.FS + var ( // KubeBindRootClusterName is the workspace to host common APIs. KubeBindRootClusterName = logicalcluster.NewPath("root:kube-bind") diff --git a/contrib/kcp/deploy/resources/apiexport-kube-bind.io.yaml b/contrib/kcp/deploy/resources/apiexport-kube-bind.io.yaml index f32386106..0b7b1f367 100644 --- a/contrib/kcp/deploy/resources/apiexport-kube-bind.io.yaml +++ b/contrib/kcp/deploy/resources/apiexport-kube-bind.io.yaml @@ -64,7 +64,7 @@ spec: crd: {} - group: kube-bind.io name: apiserviceexporttemplates - schema: v251022-ca928ec.apiserviceexporttemplates.kube-bind.io + schema: v251029-fadb9ed.apiserviceexporttemplates.kube-bind.io storage: crd: {} - group: kube-bind.io @@ -72,6 +72,11 @@ spec: schema: v250809-5ed76a1.apiservicenamespaces.kube-bind.io storage: crd: {} + - group: kube-bind.io + name: bindableresourcesrequests + schema: v251029-ff0a399.bindableresourcesrequests.kube-bind.io + storage: + crd: {} - group: kube-bind.io name: boundschemas schema: v250918-f26732b.boundschemas.kube-bind.io diff --git a/contrib/kcp/deploy/resources/apiresourceschema-apiserviceexporttemplates.kube-bind.io.yaml b/contrib/kcp/deploy/resources/apiresourceschema-apiserviceexporttemplates.kube-bind.io.yaml index 86d9b67b7..2ef398575 100644 --- a/contrib/kcp/deploy/resources/apiresourceschema-apiserviceexporttemplates.kube-bind.io.yaml +++ b/contrib/kcp/deploy/resources/apiresourceschema-apiserviceexporttemplates.kube-bind.io.yaml @@ -2,7 +2,7 @@ apiVersion: apis.kcp.io/v1alpha1 kind: APIResourceSchema metadata: creationTimestamp: null - name: v251022-ca928ec.apiserviceexporttemplates.kube-bind.io + name: v251029-fadb9ed.apiserviceexporttemplates.kube-bind.io spec: group: kube-bind.io names: @@ -49,9 +49,12 @@ spec: spec: description: spec specifies the template. properties: + description: + description: description is an optional description of the template. + type: string namespaces: description: |- - namespaces specifies the namespaces that should be bootstrapped as part of this template. + Namespaces specifies the namespaces that should be bootstrapped as part of this template. When objects originate from provider side, consumer does not always know the necessary details This field allows provider to pre-heat the necessary namespaces on provider side by creating APIServiceNamespace objects attached to the APIServiceExport. More namespaces can be created later by the consumer. diff --git a/contrib/kcp/deploy/resources/apiresourceschema-bindableresourcesrequests.kube-bind.io.yaml b/contrib/kcp/deploy/resources/apiresourceschema-bindableresourcesrequests.kube-bind.io.yaml new file mode 100644 index 000000000..fe5baa68e --- /dev/null +++ b/contrib/kcp/deploy/resources/apiresourceschema-bindableresourcesrequests.kube-bind.io.yaml @@ -0,0 +1,51 @@ +apiVersion: apis.kcp.io/v1alpha1 +kind: APIResourceSchema +metadata: + creationTimestamp: null + name: v251029-ff0a399.bindableresourcesrequests.kube-bind.io +spec: + group: kube-bind.io + names: + kind: BindableResourcesRequest + listKind: BindableResourcesRequestList + plural: bindableresourcesrequests + singular: bindableresourcesrequest + scope: Namespaced + versions: + - name: v1alpha2 + schema: + description: |- + BindableResourcesRequest is sent by the consumer to the service provider + to indicate which resources the user wants to bind to. It is sent after + authentication and resource selection on the service provider website. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + templateRef: + properties: + name: + description: name is the name of the APIServiceExportTemplate to bind + to. + type: string + required: + - name + type: object + type: object + served: true + storage: true + subresources: {} diff --git a/contrib/kcp/go.mod b/contrib/kcp/go.mod index ea57ebf6d..a7a5bde36 100644 --- a/contrib/kcp/go.mod +++ b/contrib/kcp/go.mod @@ -6,6 +6,7 @@ replace ( github.com/kube-bind/kube-bind => ../../ github.com/kube-bind/kube-bind/cli => ../../cli github.com/kube-bind/kube-bind/sdk => ../../sdk + github.com/kube-bind/kube-bind/web => ../../web ) // kcp pinned to a commit on main as sdk/testing requires @@ -14,12 +15,12 @@ replace ( replace github.com/kcp-dev/kcp/sdk => github.com/kcp-dev/kcp/sdk v0.28.1-0.20251003164010-742ce0ea6b8c require ( - github.com/headzoo/surf v1.0.1 github.com/kcp-dev/client-go v0.0.0-20250728134101-0355faa9361b github.com/kcp-dev/kcp v0.28.3 github.com/kcp-dev/kcp/sdk v0.28.1 github.com/kcp-dev/logicalcluster/v3 v3.0.5 github.com/kube-bind/kube-bind v0.0.0-00010101000000-000000000000 + github.com/kube-bind/kube-bind/cli v0.0.0-20250515145715-d9f20e7c840d github.com/kube-bind/kube-bind/sdk v0.4.1 github.com/spf13/pflag v1.0.7 github.com/stretchr/testify v1.10.0 @@ -30,15 +31,14 @@ require ( k8s.io/client-go v0.33.3 k8s.io/component-base v0.33.3 k8s.io/klog/v2 v2.130.1 - sigs.k8s.io/yaml v1.4.0 ) require ( cel.dev/expr v0.19.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/NYTimes/gziphandler v1.1.1 // indirect - github.com/PuerkitoBio/goquery v1.8.0 // indirect - github.com/andybalholm/cascadia v1.3.1 // indirect + github.com/PuerkitoBio/goquery v1.10.3 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect @@ -49,7 +49,6 @@ require ( github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dexidp/dex/api/v2 v2.3.0 // indirect - github.com/egymgmbh/go-prefix-writer v0.0.0-20180609083313-7326ea162eca // indirect github.com/emicklei/go-restful/v3 v3.12.1 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -64,6 +63,7 @@ require ( github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.23.2 // indirect @@ -76,18 +76,18 @@ require ( github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect + github.com/headzoo/surf v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kcp-dev/apimachinery/v2 v2.0.1-0.20250728122101-adbf20db3e51 // indirect github.com/kcp-dev/kcp/pkg/apis v0.11.0 // indirect github.com/kcp-dev/multicluster-provider v0.2.1-0.20251002133408-9a8d21dc2872 // indirect - github.com/kube-bind/kube-bind/cli v0.0.0-20250515145715-d9f20e7c840d // indirect + github.com/kube-bind/kube-bind/web v0.0.0-00010101000000-000000000000 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/martinlindhe/base36 v1.1.1 // indirect - github.com/mdp/qrterminal/v3 v3.2.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -143,7 +143,6 @@ require ( k8s.io/api v0.33.3 // indirect k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect - rsc.io/qr v0.2.0 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/controller-runtime v0.21.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect @@ -152,6 +151,7 @@ require ( sigs.k8s.io/multicluster-runtime v0.21.0-alpha.9.0.20251002124257-36facc7fbe82 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect ) replace ( diff --git a/contrib/kcp/go.sum b/contrib/kcp/go.sum index d4f0c2406..567757d2e 100644 --- a/contrib/kcp/go.sum +++ b/contrib/kcp/go.sum @@ -4,10 +4,10 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25 github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= -github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= -github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= -github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= -github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= +github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -35,12 +35,10 @@ github.com/dexidp/dex/api/v2 v2.3.0 h1:gv79YqTBTGU6z/QE3RNFzlS6KrPRUudVUN8o858gp github.com/dexidp/dex/api/v2 v2.3.0/go.mod h1:y9As69T4WZOERCS/CfB9D8Dbb12tafU9ywv2ZZLf4EI= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/egymgmbh/go-prefix-writer v0.0.0-20180609083313-7326ea162eca h1:7oodhZp9MZW0DBkrZXyUsJWKQFy35SVxjZ8K4vHXnk8= -github.com/egymgmbh/go-prefix-writer v0.0.0-20180609083313-7326ea162eca/go.mod h1:UhMFM+dnOcm1f0Pve8uqRaxAhEYki+/CuA2BTDp2T04= github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= -github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= +github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -73,6 +71,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -84,6 +84,7 @@ github.com/google/cel-go v0.22.1/go.mod h1:BuznPXXfQDpXKWQ9sPW3TzlAJN5zzFe+i9tIs github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -172,8 +173,6 @@ github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4 github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/martinlindhe/base36 v1.1.1 h1:1F1MZ5MGghBXDZ2KJ3QfxmiydlWOGB8HCEtkap5NkVg= github.com/martinlindhe/base36 v1.1.1/go.mod h1:vMS8PaZ5e/jV9LwFKlm0YLnXl/hpOihiBxKkIoc3g08= -github.com/mdp/qrterminal/v3 v3.2.0 h1:qteQMXO3oyTK4IHwj2mWsKYYRBOp1Pj2WRYFYYNTCdk= -github.com/mdp/qrterminal/v3 v3.2.0/go.mod h1:XGGuua4Lefrl7TLEsSONiD+UEjQXJZ4mPzF+gWYIJkk= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -246,6 +245,7 @@ github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= go.etcd.io/etcd/api/v3 v3.5.21 h1:A6O2/JDb3tvHhiIz3xf9nJ7REHvtEFJJ3veW3FbCnS8= @@ -293,19 +293,36 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= @@ -313,23 +330,51 @@ golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= @@ -338,6 +383,10 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -378,8 +427,6 @@ k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUy k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= -rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= diff --git a/contrib/kcp/test/e2e/backend.go b/contrib/kcp/test/e2e/backend.go index a2330a735..e36be7e26 100644 --- a/contrib/kcp/test/e2e/backend.go +++ b/contrib/kcp/test/e2e/backend.go @@ -19,26 +19,29 @@ package e2e import ( "fmt" "net/http" + "strings" "testing" "time" kcpclientset "github.com/kcp-dev/kcp/sdk/client/clientset/versioned/cluster" kcptestinghelpers "github.com/kcp-dev/kcp/sdk/testing/helpers" - kcptestingserver "github.com/kcp-dev/kcp/sdk/testing/server" "github.com/kcp-dev/logicalcluster/v3" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/rest" kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" "github.com/kube-bind/kube-bind/test/e2e/framework" ) -func bootstrapBackend(t *testing.T, server kcptestingserver.RunningServer, scope kubebindv1alpha2.InformerScope) string { +func bootstrapBackend(t *testing.T, rest *rest.Config, scope kubebindv1alpha2.InformerScope) string { t.Helper() t.Log("Bootstrapping backend") - client, err := kcpclientset.NewForConfig(server.BaseConfig(t)) + rest.Host = strings.Split(rest.Host, "/clusters/")[0] + + client, err := kcpclientset.NewForConfig(rest) require.NoError(t, err) exportUrl := "" @@ -58,7 +61,7 @@ func bootstrapBackend(t *testing.T, server kcptestingserver.RunningServer, scope }, wait.ForeverTestTimeout, time.Millisecond*100) require.NotEmpty(t, exportUrl, "APIExportEndpointSlice URL is empty") - _, backendKubeconfig := wsConfig(t, server, logicalcluster.NewPath("root").Join("kube-bind")) + _, backendKubeconfig := wsConfig(t, rest, logicalcluster.NewPath("root").Join("kube-bind")) t.Log("Starting kube-bind backend for kcp") addr, _ := framework.StartBackend(t, diff --git a/contrib/kcp/test/e2e/binding.go b/contrib/kcp/test/e2e/binding.go new file mode 100644 index 000000000..08d148617 --- /dev/null +++ b/contrib/kcp/test/e2e/binding.go @@ -0,0 +1,85 @@ +/* +Copyright 2025 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 e2e + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/rest" + + bindapiservice "github.com/kube-bind/kube-bind/cli/pkg/kubectl/bind-apiservice/plugin" + kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" + "github.com/kube-bind/kube-bind/test/e2e/framework" +) + +func performBinding( + t *testing.T, + consumerCfg *rest.Config, + templateRef string, + crdName string, + kubeBindConfig string, +) { + // Implementation of the binding process using the provided parameters + // This is a placeholder for the actual binding logic + t.Logf("Performing binding with templateRef: %s", templateRef) + + // 1. Get APIServiceExportRequest from provider + c := framework.GetKubeBindRestClient(t, kubeBindConfig) + + bindResponse, err := c.Bind(t.Context(), &kubebindv1alpha2.BindableResourcesRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-binding", + }, + TemplateRef: kubebindv1alpha2.APIServiceExportTemplateRef{ + Name: templateRef, + }, + }) + require.NoError(t, err) + require.NotNil(t, bindResponse) + + iostreams, _, _, _ := genericclioptions.NewTestIOStreams() + binderOpts := &bindapiservice.BinderOptions{ + IOStreams: iostreams, + SkipKonnector: true, + } + + binder := bindapiservice.NewBinder(consumerCfg, binderOpts) + result, err := binder.BindFromResponse(t.Context(), bindResponse) + require.NoError(t, err) + require.Len(t, result, 1) + + t.Logf("Waiting for %s CRD to be created on consumer side", templateRef) + crdClient := framework.ApiextensionsClient(t, consumerCfg).ApiextensionsV1().CustomResourceDefinitions() + require.Eventually(t, func() bool { + crds, err := crdClient.List(t.Context(), metav1.ListOptions{}) + if err != nil { + return false + } + for _, crd := range crds.Items { + if strings.Contains(crd.Name, crdName) { + return true + } + } + return false + }, wait.ForeverTestTimeout, time.Millisecond*100, "waiting for %s CRD to be created on consumer side", crdName) +} diff --git a/contrib/kcp/test/e2e/browser.go b/contrib/kcp/test/e2e/browser.go deleted file mode 100644 index c6a5e7129..000000000 --- a/contrib/kcp/test/e2e/browser.go +++ /dev/null @@ -1,99 +0,0 @@ -/* -Copyright 2025 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 e2e - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/headzoo/surf" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/util/wait" - "k8s.io/cli-runtime/pkg/genericclioptions" - "k8s.io/client-go/rest" - "sigs.k8s.io/yaml" - - "github.com/kube-bind/kube-bind/test/e2e/framework" -) - -func performBindingWithBrowser(t *testing.T, backendAddr string, clusterID string, consumerCfg *rest.Config, consumerKubeconfig, resource, template string) { - bindURL := fmt.Sprintf("http://%s/clusters/%s/exports", backendAddr, clusterID) - t.Logf("Bind URL: %s", bindURL) - - // Test binding dry run first (similar to happy-case test) - t.Run("Service is bound dry run", func(t *testing.T) { - authURLDryRunCh := make(chan string, 1) - go simulateKCPBrowser(t, authURLDryRunCh, template) - - iostreams, _, bufOut, _ := genericclioptions.NewTestIOStreams() - framework.Bind(t, iostreams, authURLDryRunCh, nil, bindURL, "--kubeconfig", consumerKubeconfig, "--dry-run") - _, err := yaml.YAMLToJSON(bufOut.Bytes()) - require.NoError(t, err, "Generated output is not valid YAML") - }) - - // Perform actual binding (similar to happy-case test) - t.Run("Service is bound", func(t *testing.T) { - authURLCh := make(chan string, 1) - go simulateKCPBrowser(t, authURLCh, template) - - iostreams, _, _, _ := genericclioptions.NewTestIOStreams() - invocations := make(chan framework.SubCommandInvocation, 1) - framework.Bind(t, iostreams, authURLCh, invocations, bindURL, "--kubeconfig", consumerKubeconfig) - inv := <-invocations - - inv.Args = append( - inv.Args, - "--kubeconfig="+consumerKubeconfig, - "--skip-konnector=true", - "--no-banner", - "-f=-", // api service export from stdin - ) - - framework.BindAPIService(t, inv.Stdin, "", inv.Args...) - - // Wait for CRD to be created on consumer side - t.Logf("Waiting for %s CRD to be created on consumer side", resource) - crdClient := framework.ApiextensionsClient(t, consumerCfg).ApiextensionsV1().CustomResourceDefinitions() - require.Eventually(t, func() bool { - _, err := crdClient.Get(context.Background(), resource+".wildwest.dev", metav1.GetOptions{}) - return err == nil - }, wait.ForeverTestTimeout, time.Millisecond*100) - }) -} - -// simulateKCPBrowser simulates browser interaction for kcp binding using templates. -func simulateKCPBrowser(t *testing.T, authURLCh chan string, template string) { - browser := surf.NewBrowser() - authURL := <-authURLCh - - t.Logf("Browsing to auth URL: %s", authURL) - err := browser.Open(authURL) - require.NoError(t, err, "Failed to open auth URL") - - t.Logf("Waiting for browser to be at /resources") - framework.BrowserEventuallyAtPath(t, browser, "/resources") - - t.Logf("Clicking %s template", template) - err = browser.Click("a." + template) - require.NoError(t, err, "Failed to click template link") - - t.Logf("Waiting for browser to be forwarded to client") - framework.BrowserEventuallyAtPath(t, browser, "/callback") -} diff --git a/contrib/kcp/test/e2e/kcp.go b/contrib/kcp/test/e2e/kcp.go index 41f7c0ca2..4512aef5a 100644 --- a/contrib/kcp/test/e2e/kcp.go +++ b/contrib/kcp/test/e2e/kcp.go @@ -25,7 +25,6 @@ import ( kcpapisv1alpha2 "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha2" kcpclientset "github.com/kcp-dev/kcp/sdk/client/clientset/versioned/cluster" kcptestinghelpers "github.com/kcp-dev/kcp/sdk/testing/helpers" - kcptestingserver "github.com/kcp-dev/kcp/sdk/testing/server" "github.com/kcp-dev/logicalcluster/v3" "github.com/spf13/pflag" "github.com/stretchr/testify/require" @@ -38,8 +37,8 @@ import ( "github.com/kube-bind/kube-bind/test/e2e/framework" ) -func wsConfig(t testing.TB, server kcptestingserver.RunningServer, workspace logicalcluster.Path) (*rest.Config, string) { - cfg := server.BaseConfig(t) +func wsConfig(t testing.TB, rest *rest.Config, workspace logicalcluster.Path) (*rest.Config, string) { + cfg := rest cfg.Host += "/clusters/" + workspace.String() kubeconfig := framework.WriteKubeconfig(t, @@ -127,11 +126,11 @@ func createApiBinding(t testing.TB, client *kcpclientset.ClusterClientset, path return inCluster } -func bootstrapKCP(t testing.TB, server kcptestingserver.RunningServer) { +func bootstrapKCP(t testing.TB, rest *rest.Config) { t.Helper() t.Log("Bootstrapping kcp") - cfg := server.BaseConfig(t) + cfg := rest cfg.Host += "/clusters/root" adminApiCfg := framework.RestToKubeconfig(cfg, "default") adminKubeconfig := framework.WriteKubeconfig(t, adminApiCfg, "admin.kubeconfig") diff --git a/contrib/kcp/test/e2e/kcp_test.go b/contrib/kcp/test/e2e/kcp_test.go index 21136d1b9..d5190ef30 100644 --- a/contrib/kcp/test/e2e/kcp_test.go +++ b/contrib/kcp/test/e2e/kcp_test.go @@ -18,12 +18,12 @@ package e2e import ( "fmt" + "path" "strings" "testing" "time" kcpclientset "github.com/kcp-dev/kcp/sdk/client/clientset/versioned/cluster" - kcptesting "github.com/kcp-dev/kcp/sdk/testing" kcptestinghelpers "github.com/kcp-dev/kcp/sdk/testing/helpers" "github.com/kcp-dev/logicalcluster/v3" "github.com/stretchr/testify/require" @@ -31,24 +31,30 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" + boostrapdeploy "github.com/kube-bind/kube-bind/contrib/kcp/deploy" kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" "github.com/kube-bind/kube-bind/test/e2e/framework" ) +// TODO: Parallelizm is disabled due to bind-login overlapping server usage +// We need to refactor config machienery to allow multiple servers to be used in parallel tests +// https://github.com/kube-bind/kube-bind/issues/361 + func TestKCPClusterScope(t *testing.T) { - t.Parallel() - testKcpIntegration(t, kubebindv1alpha2.ClusterScope) + // t.Parallel() + testKcpIntegration(t, "cc", kubebindv1alpha2.ClusterScope) } func TestKCPNamespacedScope(t *testing.T) { - t.Parallel() - testKcpIntegration(t, kubebindv1alpha2.NamespacedScope) + // t.Parallel() + testKcpIntegration(t, "nc", kubebindv1alpha2.NamespacedScope) } -func testKcpIntegration(t *testing.T, scope kubebindv1alpha2.InformerScope) { +func testKcpIntegration(t *testing.T, name string, scope kubebindv1alpha2.InformerScope) { t.Helper() t.Logf("Testing kcp integration with informer scope %s, tempdir: %s", scope, t.TempDir()) @@ -56,30 +62,29 @@ func testKcpIntegration(t *testing.T, scope kubebindv1alpha2.InformerScope) { framework.StartDex(t) // kcp bootstrap - server := kcptesting.PrivateKcpServer(t) - bootstrapKCP(t, server) + bootstrapKCP(t, framework.ClientConfig(t)) + + suffix := framework.RandomString(4) // consumer t.Log("Create consumer workspace") - consumerWsPath, _ := kcptesting.NewWorkspaceFixture(t, server, logicalcluster.NewPath("root"), kcptesting.WithName("consumer")) - - t.Log("Create a consumer kubeconfig") - consumerCfg, consumerKubeconfig := wsConfig(t, server, consumerWsPath) + consumerWsName := fmt.Sprintf("%s-consumer-%s", name, suffix) + consumerCfg, consumerKubeconfigPath := framework.NewWorkspace(t, framework.ClientConfig(t), framework.WithStaticName(consumerWsName)) t.Log("Start konnector for consumer workspace") - framework.StartKonnector(t, consumerCfg, "--kubeconfig="+consumerKubeconfig) + framework.StartKonnector(t, consumerCfg, "--kubeconfig="+consumerKubeconfigPath) // backend - backendAddr := bootstrapBackend(t, server, scope) + backendAddr := bootstrapBackend(t, framework.ClientConfig(t), scope) // provider t.Log("Create provider workspace") - providerWsPath, _ := kcptesting.NewWorkspaceFixture(t, server, logicalcluster.NewPath("root"), kcptesting.WithName("provider")) + providerWsName := fmt.Sprintf("%s-provider-%s", name, suffix) + providerWsPath := logicalcluster.NewPath("root").Join(providerWsName) + providerCfg, _ := framework.NewWorkspace(t, framework.ClientConfig(t), framework.WithStaticName(providerWsName)) - t.Log("Create a provider kubeconfig") - providerCfg, _ := wsConfig(t, server, providerWsPath) - - cfg := server.BaseConfig(t) + cfg := framework.ClientConfig(t) + cfg.Host = strings.Split(cfg.Host, "/clusters/")[0] kcpClusterClient, err := kcpclientset.NewForConfig(cfg) require.NoError(t, err, "failed to create kcp client") @@ -102,15 +107,20 @@ func testKcpIntegration(t *testing.T, scope kubebindv1alpha2.InformerScope) { ) t.Log("Applying example APIExport, APIResourceSchemas and templates to provider workspace") - framework.ApplyFiles(t, - providerCfg, - "../../deploy/examples/apiexport.yaml", - "../../deploy/examples/apiresourceschema-cowboys.yaml", // namespaced - "../../deploy/examples/apiresourceschema-sheriffs.yaml", // cluster scoped - "../../deploy/examples/template-cowboys.yaml", // template for cowboys - "../../deploy/examples/template-sheriffs.yaml", // template for sheriffs - "../../deploy/examples/collection-wildwest.yaml", - ) + + files := []string{"examples/apiexport.yaml", + "examples/apiresourceschema-cowboys.yaml", // namespaced + "examples/apiresourceschema-sheriffs.yaml", // cluster scoped + "examples/template-cowboys.yaml", // template for cowboys + "examples/template-sheriffs.yaml", // template for sheriffs + "examples/collection-wildwest.yaml", + } + for _, f := range files { + data, err := boostrapdeploy.Examples.ReadFile(f) + require.NoError(t, err, "failed to read example file %s", f) + + framework.ApplyManifest(t, providerCfg, data) + } t.Log("Bind the APIExport locally") createApiBinding(t, @@ -141,20 +151,29 @@ func testKcpIntegration(t *testing.T, scope kubebindv1alpha2.InformerScope) { // kube-bind process t.Log("Perform binding process with browser") - var kind, resource, template string + var templateRef, kind, resource string switch scope { case kubebindv1alpha2.ClusterScope: kind = "Sheriff" resource = "sheriffs" - template = "sheriffs" + templateRef = "sheriffs" case kubebindv1alpha2.NamespacedScope: kind = "Cowboy" resource = "cowboys" - template = "cowboys" + templateRef = "cowboys" default: require.Fail(t, "unhandled scope %q", scope) } - performBindingWithBrowser(t, backendAddr, providerClusterID, consumerCfg, consumerKubeconfig, resource, template) + + kubeBindConfig := path.Join(framework.WorkDir, "kube-bind-config-kcp.yaml") + + iostreams, _, _, _ := genericclioptions.NewTestIOStreams() + authURLDryRunCh := make(chan string, 1) + go framework.SimulateBrowser(t, authURLDryRunCh) + framework.Login(t, iostreams, authURLDryRunCh, kubeBindConfig, fmt.Sprintf("http://%s/api/exports", backendAddr), providerClusterID) + + t.Logf("Performing binding using template %s", templateRef) + performBinding(t, consumerCfg, templateRef, resource, kubeBindConfig) t.Log("Testing resource creation and synchronization...") testKCPResourceSync(t, consumerCfg, providerCfg, scope, kind, resource) diff --git a/deploy/charts/backend/crds/kube-bind.io_apiserviceexporttemplates.yaml b/deploy/charts/backend/crds/kube-bind.io_apiserviceexporttemplates.yaml index 5ac00f14d..f7e322217 100644 --- a/deploy/charts/backend/crds/kube-bind.io_apiserviceexporttemplates.yaml +++ b/deploy/charts/backend/crds/kube-bind.io_apiserviceexporttemplates.yaml @@ -52,9 +52,12 @@ spec: spec: description: spec specifies the template. properties: + description: + description: description is an optional description of the template. + type: string namespaces: description: |- - namespaces specifies the namespaces that should be bootstrapped as part of this template. + Namespaces specifies the namespaces that should be bootstrapped as part of this template. When objects originate from provider side, consumer does not always know the necessary details This field allows provider to pre-heat the necessary namespaces on provider side by creating APIServiceNamespace objects attached to the APIServiceExport. More namespaces can be created later by the consumer. diff --git a/deploy/charts/backend/crds/kube-bind.io_bindableresourcesrequests.yaml b/deploy/charts/backend/crds/kube-bind.io_bindableresourcesrequests.yaml new file mode 100644 index 000000000..15fbf05db --- /dev/null +++ b/deploy/charts/backend/crds/kube-bind.io_bindableresourcesrequests.yaml @@ -0,0 +1,53 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: bindableresourcesrequests.kube-bind.io +spec: + group: kube-bind.io + names: + kind: BindableResourcesRequest + listKind: BindableResourcesRequestList + plural: bindableresourcesrequests + singular: bindableresourcesrequest + scope: Namespaced + versions: + - name: v1alpha2 + schema: + openAPIV3Schema: + description: |- + BindableResourcesRequest is sent by the consumer to the service provider + to indicate which resources the user wants to bind to. It is sent after + authentication and resource selection on the service provider website. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + templateRef: + properties: + name: + description: name is the name of the APIServiceExportTemplate to bind + to. + type: string + required: + - name + type: object + type: object + served: true + storage: true diff --git a/deploy/crd/kube-bind.io_apiserviceexporttemplates.yaml b/deploy/crd/kube-bind.io_apiserviceexporttemplates.yaml index 5ac00f14d..f7e322217 100644 --- a/deploy/crd/kube-bind.io_apiserviceexporttemplates.yaml +++ b/deploy/crd/kube-bind.io_apiserviceexporttemplates.yaml @@ -52,9 +52,12 @@ spec: spec: description: spec specifies the template. properties: + description: + description: description is an optional description of the template. + type: string namespaces: description: |- - namespaces specifies the namespaces that should be bootstrapped as part of this template. + Namespaces specifies the namespaces that should be bootstrapped as part of this template. When objects originate from provider side, consumer does not always know the necessary details This field allows provider to pre-heat the necessary namespaces on provider side by creating APIServiceNamespace objects attached to the APIServiceExport. More namespaces can be created later by the consumer. diff --git a/deploy/crd/kube-bind.io_bindableresourcesrequests.yaml b/deploy/crd/kube-bind.io_bindableresourcesrequests.yaml new file mode 100644 index 000000000..15fbf05db --- /dev/null +++ b/deploy/crd/kube-bind.io_bindableresourcesrequests.yaml @@ -0,0 +1,53 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.3 + name: bindableresourcesrequests.kube-bind.io +spec: + group: kube-bind.io + names: + kind: BindableResourcesRequest + listKind: BindableResourcesRequestList + plural: bindableresourcesrequests + singular: bindableresourcesrequest + scope: Namespaced + versions: + - name: v1alpha2 + schema: + openAPIV3Schema: + description: |- + BindableResourcesRequest is sent by the consumer to the service provider + to indicate which resources the user wants to bind to. It is sent after + authentication and resource selection on the service provider website. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + templateRef: + properties: + name: + description: name is the name of the APIServiceExportTemplate to bind + to. + type: string + required: + - name + type: object + type: object + served: true + storage: true diff --git a/docs/content/setup/.pages b/docs/content/setup/.pages index 285ade1aa..5f0c6ac8a 100644 --- a/docs/content/setup/.pages +++ b/docs/content/setup/.pages @@ -1,6 +1,6 @@ nav: - index.md + - kubectl-plugin.md - quickstart.md - local-setup-with-kind.md - - helm.md - - kubectl-plugin.md + - helm.md \ No newline at end of file diff --git a/docs/content/setup/helm.md b/docs/content/setup/helm.md index c83cd0d50..dabcbdc1b 100644 --- a/docs/content/setup/helm.md +++ b/docs/content/setup/helm.md @@ -50,11 +50,11 @@ The following prerequisites are required. Click the links below for detailed set kube-bind oci://ghcr.io/kube-bind/charts/backend --version ${VERSION} # Or install a specific development version - # helm upgrade --install \ - # --namespace kube-bind \ - # --create-namespace \ - # --values ./deploy/charts/backend/examples/values-local-development.yaml \ - # kube-bind oci://ghcr.io/kube-bind/charts/backend --version 0.0.0-21d91e9 + helm upgrade --install \ + --namespace kube-bind \ + --create-namespace \ + --values ./deploy/charts/backend/examples/values-local-development.yaml \ + kube-bind oci://ghcr.io/kube-bind/charts/backend --version 0.0.0-fadb9edd26c0202f4a9511ee9d71b9e5f43672b9 ``` 4. **Seed with example resources (optional):** diff --git a/docs/content/setup/kcp-setup.md b/docs/content/setup/kcp-setup.md index e831a0968..8969c48b5 100644 --- a/docs/content/setup/kcp-setup.md +++ b/docs/content/setup/kcp-setup.md @@ -89,6 +89,7 @@ Start the backend with kcp provider: --cookie-signing-key=bGMHz7SR9XcI9JdDB68VmjQErrjbrAR9JdVqjAOKHzE= \ --cookie-encryption-key=wadqi4u+w0bqnSrVFtM38Pz2ykYVIeeadhzT34XlC1Y= \ --schema-source apiresourceschemas + --consumer-scope=cluster ``` ### 5. Create Provider Workspace @@ -131,6 +132,11 @@ kubectl create -f contrib/kcp/deploy/examples/apiresourceschema-sheriffs.yaml # Enable recursive binding kubectl kcp bind apiexport root:provider:cowboys-stable + +# Create templates and catalog +kubectl create -f contrib/kcp/deploy/examples/template-cowboys.yaml +kubectl create -f contrib/kcp/deploy/examples/template-sheriffs.yaml +kubectl create -f contrib/kcp/deploy/examples/collection-wildwest.yaml ``` ### 8. Get Logical Cluster Information @@ -159,23 +165,8 @@ kubectl ws create consumer --enter Generate the APIServiceExport YAML: ```bash -./bin/kubectl-bind http://127.0.0.1:8080/clusters//exports --dry-run -o yaml > apiserviceexport.yaml -``` - -Extract the kubeconfig for binding: - -```bash -kubectl get secret -n kube-bind -o jsonpath='{.data.kubeconfig}' | base64 -d > remote.kubeconfig -``` - -Perform the binding: - -```bash -./bin/kubectl-bind apiservice \ - --remote-kubeconfig remote.kubeconfig \ - -f apiserviceexport.yaml \ - --skip-konnector \ - --remote-namespace kube-bind- +./bin/kubectl-bind login http://127.0.0.1:8080 --cluster +./bin/kubectl-bind --skip-konnector ``` ### 3. Start Konnector @@ -192,46 +183,3 @@ Create example resources: ```bash kubectl apply -f contrib/kcp/deploy/examples/cowboy.yaml ``` - -## Advanced Features - -### Multiple Consumers - -You can create multiple consumer workspaces to test multi-tenant scenarios: - -```bash -# Create second consumer -cp .kcp/admin.kubeconfig .kcp/consumer2.kubeconfig -export KUBECONFIG=.kcp/consumer2.kubeconfig -kubectl ws use :root -kubectl ws create consumer2 --enter - -# Repeat binding process with different namespace -# Start konnector on different port -go run ./cmd/konnector/ --lease-namespace default --server-address :8091 -``` - -### Debugging - -To debug the setup, use the following commands: - -```bash -# Switch to debug workspace -cp .kcp/admin.kubeconfig .kcp/debug.kubeconfig -export KUBECONFIG=.kcp/debug.kubeconfig -kubectl ws use :root:kube-bind - -# Check available resources -kubectl-s "$(kubectl get apiexportendpointslice kube-bind.io -o jsonpath="{.status.endpoints[0].url}")/clusters/*" api-resources - -# List CRDs -kubectl-s "$(kubectl get apiexportendpointslice kube-bind.io -o jsonpath="{.status.endpoints[0].url}")/clusters/*" get crd -``` - -## Key Differences from Standard Setup - -- **Provider Selection**: Uses `--multicluster-runtime-provider kcp` flag -- **Workspace Management**: Requires kcp workspace creation and management -- **APIExport Integration**: Leverages kcp's APIExport mechanism to enable shared backed service. -- **URL Structure**: Uses kcp-specific URLs with cluster identifiers. In production, this should be abstracted by a service wrapper. -- **Advanced Isolation**: Provides workspace-level isolation beyond namespaces diff --git a/docs/content/setup/kubectl-plugin.md b/docs/content/setup/kubectl-plugin.md index 5b8324fb6..ad106b855 100644 --- a/docs/content/setup/kubectl-plugin.md +++ b/docs/content/setup/kubectl-plugin.md @@ -10,6 +10,6 @@ kube-bind provides kubectl plugins to interact with kube-bind protocol. or use [krew](https://krew.sigs.k8s.io/): ```sh -$ kubectl krew index add bind https://github.com/kube-bind/krew-index.git -$ kubectl krew install bind/bind +kubectl krew index add bind https://github.com/kube-bind/krew-index.git +kubectl krew install bind/bind ``` diff --git a/docs/content/setup/quickstart.md b/docs/content/setup/quickstart.md index c20b32ee8..b6cd08022 100644 --- a/docs/content/setup/quickstart.md +++ b/docs/content/setup/quickstart.md @@ -86,7 +86,7 @@ kubectl ws create provider --enter --oidc-issuer-client-secret=ZXhhbXBsZS1hcHAtc2VjcmV0 \ --oidc-issuer-client-id=kube-bind \ --oidc-issuer-url=http://127.0.0.1:5556/dex \ - --oidc-callback-url=http://127.0.0.1:8080/callback \ + --oidc-callback-url=http://127.0.0.1:8080/api/callback \ --pretty-name="BigCorp.com" \ --namespace-prefix="kube-bind-" \ --cookie-signing-key=bGMHz7SR9XcI9JdDB68VmjQErrjbrAR9JdVqjAOKHzE= \ @@ -118,7 +118,7 @@ kubectl ws create consumer --enter Now create the APIServiceExportRequest: ```shell -./bin/kubectl-bind http://127.0.0.1:8080/exports --dry-run -o yaml > apiserviceexport.yaml +./bin/kubectl-bind http://127.0.0.1:8080 --dry-run -o yaml > apiserviceexport.yaml # This will wait for konnector to be ready. Once this gets running - start the konnector bellow # IMPORTANT: Check namespace to be used! diff --git a/docs/generators/cli-doc/go.mod b/docs/generators/cli-doc/go.mod index 4efa980ef..6b292cfdc 100644 --- a/docs/generators/cli-doc/go.mod +++ b/docs/generators/cli-doc/go.mod @@ -59,7 +59,6 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.12 // indirect - github.com/mdp/qrterminal/v3 v3.2.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -120,7 +119,6 @@ require ( k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect - rsc.io/qr v0.2.0 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/kustomize/api v0.19.0 // indirect diff --git a/docs/generators/cli-doc/go.sum b/docs/generators/cli-doc/go.sum index 77841d0be..1505fd6f4 100644 --- a/docs/generators/cli-doc/go.sum +++ b/docs/generators/cli-doc/go.sum @@ -122,8 +122,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxmAOow= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mdp/qrterminal/v3 v3.2.0 h1:qteQMXO3oyTK4IHwj2mWsKYYRBOp1Pj2WRYFYYNTCdk= -github.com/mdp/qrterminal/v3 v3.2.0/go.mod h1:XGGuua4Lefrl7TLEsSONiD+UEjQXJZ4mPzF+gWYIJkk= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -332,8 +330,6 @@ k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUy k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= -rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= diff --git a/go.mod b/go.mod index dd61a9704..faa5d90cd 100644 --- a/go.mod +++ b/go.mod @@ -6,12 +6,14 @@ replace ( github.com/kube-bind/kube-bind => ./ github.com/kube-bind/kube-bind/cli => ./cli github.com/kube-bind/kube-bind/sdk => ./sdk + github.com/kube-bind/kube-bind/web => ./web ) require ( github.com/coreos/go-oidc/v3 v3.15.0 github.com/dexidp/dex/api/v2 v2.3.0 github.com/evanphx/json-patch/v5 v5.9.11 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/go-cmp v0.7.0 github.com/gorilla/mux v1.8.0 github.com/gorilla/securecookie v1.1.1 @@ -22,6 +24,7 @@ require ( github.com/kcp-dev/multicluster-provider v0.2.1-0.20251002133408-9a8d21dc2872 github.com/kube-bind/kube-bind/cli v0.0.0-20250515145715-d9f20e7c840d github.com/kube-bind/kube-bind/sdk v0.4.1 + github.com/kube-bind/kube-bind/web v0.0.0-00010101000000-000000000000 github.com/martinlindhe/base36 v1.1.1 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 @@ -50,8 +53,8 @@ require ( cel.dev/expr v0.19.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/NYTimes/gziphandler v1.1.1 // indirect - github.com/PuerkitoBio/goquery v1.8.0 // indirect - github.com/andybalholm/cascadia v1.3.1 // indirect + github.com/PuerkitoBio/goquery v1.10.3 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect @@ -62,7 +65,6 @@ require ( github.com/coreos/go-systemd/v22 v22.5.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.1 // indirect - github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect @@ -97,7 +99,6 @@ require ( github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mdp/qrterminal/v3 v3.2.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect @@ -152,7 +153,6 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect k8s.io/gengo/v2 v2.0.0-20250207200755-1244d31929d7 // indirect k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect - rsc.io/qr v0.2.0 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/kustomize/api v0.19.0 // indirect diff --git a/go.sum b/go.sum index e3387965e..9076fc143 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,10 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25 github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= -github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= -github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= -github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= -github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= +github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -39,8 +39,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emicklei/go-restful/v3 v3.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= -github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= +github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -77,6 +77,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -88,6 +90,7 @@ github.com/google/cel-go v0.23.2/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -163,8 +166,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mdp/qrterminal/v3 v3.2.0 h1:qteQMXO3oyTK4IHwj2mWsKYYRBOp1Pj2WRYFYYNTCdk= -github.com/mdp/qrterminal/v3 v3.2.0/go.mod h1:XGGuua4Lefrl7TLEsSONiD+UEjQXJZ4mPzF+gWYIJkk= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -240,6 +241,7 @@ github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= go.etcd.io/etcd/api/v3 v3.5.21 h1:A6O2/JDb3tvHhiIz3xf9nJ7REHvtEFJJ3veW3FbCnS8= @@ -287,12 +289,22 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -301,7 +313,14 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= @@ -309,26 +328,54 @@ golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= @@ -337,6 +384,10 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -401,8 +452,6 @@ k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUy k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= -rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= diff --git a/hack/dex-config-dev.yaml b/hack/dex-config-dev.yaml index 94c44579c..9a4220dad 100644 --- a/hack/dex-config-dev.yaml +++ b/hack/dex-config-dev.yaml @@ -110,6 +110,7 @@ staticClients: - id: kube-bind redirectURIs: - 'http://127.0.0.1:8080/callback' + - 'http://127.0.0.1:8080/api/callback' name: 'Kube Bind' secret: ZXhhbXBsZS1hcHAtc2VjcmV0 diff --git a/hack/run-frontend.sh b/hack/run-frontend.sh new file mode 100755 index 000000000..4b49535dc --- /dev/null +++ b/hack/run-frontend.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +# Copyright 2025 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. + +set -e + +echo "Building frontend..." + +# Navigate to web directory +cd web + +# Install dependencies if node_modules doesn't exist +if [ ! -d "node_modules" ]; then + echo "Installing frontend dependencies..." + npm install +fi + +# Build the frontend +echo "Running dev application..." +npm run dev diff --git a/sdk/apis/kubebind/v1alpha2/apiserviceexporttemplate_types.go b/sdk/apis/kubebind/v1alpha2/apiserviceexporttemplate_types.go index 94623c66e..1f8a39528 100644 --- a/sdk/apis/kubebind/v1alpha2/apiserviceexporttemplate_types.go +++ b/sdk/apis/kubebind/v1alpha2/apiserviceexporttemplate_types.go @@ -55,6 +55,10 @@ func (in *APIServiceExportTemplate) SetConditions(conditions conditionsapi.Condi // APIServiceExportTemplateSpec defines the desired state of APIServiceExportTemplate. type APIServiceExportTemplateSpec struct { + // description is an optional description of the template. + // + // +optional + Description string `json:"description,omitempty"` // scope defines the scope of the resources in this template. // +required // +kubebuilder:validation:Required @@ -72,12 +76,13 @@ type APIServiceExportTemplateSpec struct { // +optional PermissionClaims []PermissionClaim `json:"permissionClaims,omitempty"` - // namespaces specifies the namespaces that should be bootstrapped as part of this template. + // Namespaces specifies the namespaces that should be bootstrapped as part of this template. // When objects originate from provider side, consumer does not always know the necessary details // This field allows provider to pre-heat the necessary namespaces on provider side by creating // APIServiceNamespace objects attached to the APIServiceExport. More namespaces can be created later by the consumer. // // +optional + // +kubebuilder:validation:Optional Namespaces []Namespaces `json:"namespaces,omitempty"` } diff --git a/sdk/apis/kubebind/v1alpha2/bindingresponse_types.go b/sdk/apis/kubebind/v1alpha2/bindingresponse_types.go index f99acaac4..9733e75db 100644 --- a/sdk/apis/kubebind/v1alpha2/bindingresponse_types.go +++ b/sdk/apis/kubebind/v1alpha2/bindingresponse_types.go @@ -17,11 +17,16 @@ limitations under the License. package v1alpha2 import ( + "errors" + "fmt" + "strings" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/validation" ) -// BindingResponse is a non-CRUD resource that is returned by the server after +// BindingResourceResponse is a non-CRUD resource that is returned by the server after // authentication and resource selection on the service prpvider website. It returns // a list of requests of possibly different types that kubectl bind has to // pass to the sub-command kubect-bind-, e.g. kubectl-bind-apiservice for @@ -29,7 +34,7 @@ import ( // // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:storageversion -type BindingResponse struct { +type BindingResourceResponse struct { metav1.TypeMeta `json:",inline"` // authentication is data specific to the authentication method that was used. @@ -73,3 +78,38 @@ type BindingResponseAuthenticationOAuth2CodeGrant struct { // id is the ID of the authenticated user. It is for informational purposes only. ID string `json:"id"` } + +// BindableResourcesRequest is sent by the consumer to the service provider +// to indicate which resources the user wants to bind to. It is sent after +// authentication and resource selection on the service provider website. +type BindableResourcesRequest struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + TemplateRef APIServiceExportTemplateRef `json:"templateRef,omitempty"` +} + +type APIServiceExportTemplateRef struct { + // name is the name of the APIServiceExportTemplate to bind to. + // + // +required + // +kubebuilder:validation:Required + Name string `json:"name"` +} + +func (r *BindableResourcesRequest) Validate() error { + if r.TemplateRef.Name == "" { + return errors.New("templateRef.name is required") + } + + if r.Name == "" { + return errors.New("name is required") + } + + // Validate DNS name format for the request name + if errs := validation.IsDNS1123Label(r.Name); len(errs) > 0 { + return fmt.Errorf("name %q is not a valid DNS label: %s", r.Name, strings.Join(errs, ", ")) + } + + return nil +} diff --git a/sdk/apis/kubebind/v1alpha2/register.go b/sdk/apis/kubebind/v1alpha2/register.go index a23947385..8f71322f8 100644 --- a/sdk/apis/kubebind/v1alpha2/register.go +++ b/sdk/apis/kubebind/v1alpha2/register.go @@ -59,7 +59,7 @@ func addKnownTypes(scheme *runtime.Scheme) error { &ClusterBinding{}, &ClusterBindingList{}, &BindingProvider{}, - &BindingResponse{}, + &BindingResourceResponse{}, &APIServiceExportTemplate{}, &APIServiceExportTemplateList{}, &Collection{}, diff --git a/sdk/apis/kubebind/v1alpha2/zz_generated.deepcopy.go b/sdk/apis/kubebind/v1alpha2/zz_generated.deepcopy.go index 7ac1c67a0..12beabb98 100644 --- a/sdk/apis/kubebind/v1alpha2/zz_generated.deepcopy.go +++ b/sdk/apis/kubebind/v1alpha2/zz_generated.deepcopy.go @@ -547,6 +547,22 @@ func (in *APIServiceExportTemplateList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *APIServiceExportTemplateRef) DeepCopyInto(out *APIServiceExportTemplateRef) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIServiceExportTemplateRef. +func (in *APIServiceExportTemplateRef) DeepCopy() *APIServiceExportTemplateRef { + if in == nil { + return nil + } + out := new(APIServiceExportTemplateRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *APIServiceExportTemplateReference) DeepCopyInto(out *APIServiceExportTemplateReference) { *out = *in @@ -735,6 +751,25 @@ func (in *AuthenticationMethod) DeepCopy() *AuthenticationMethod { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindableResourcesRequest) DeepCopyInto(out *BindableResourcesRequest) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.TemplateRef = in.TemplateRef + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindableResourcesRequest. +func (in *BindableResourcesRequest) DeepCopy() *BindableResourcesRequest { + if in == nil { + return nil + } + out := new(BindableResourcesRequest) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BindingProvider) DeepCopyInto(out *BindingProvider) { *out = *in @@ -768,7 +803,7 @@ func (in *BindingProvider) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *BindingResponse) DeepCopyInto(out *BindingResponse) { +func (in *BindingResourceResponse) DeepCopyInto(out *BindingResourceResponse) { *out = *in out.TypeMeta = in.TypeMeta in.Authentication.DeepCopyInto(&out.Authentication) @@ -787,18 +822,18 @@ func (in *BindingResponse) DeepCopyInto(out *BindingResponse) { return } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindingResponse. -func (in *BindingResponse) DeepCopy() *BindingResponse { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindingResourceResponse. +func (in *BindingResourceResponse) DeepCopy() *BindingResourceResponse { if in == nil { return nil } - out := new(BindingResponse) + out := new(BindingResourceResponse) in.DeepCopyInto(out) return out } // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *BindingResponse) DeepCopyObject() runtime.Object { +func (in *BindingResourceResponse) DeepCopyObject() runtime.Object { if c := in.DeepCopy(); c != nil { return c } diff --git a/test/e2e/bind/happy-case_test.go b/test/e2e/bind/happy-case_test.go index 06ded2270..3e9e52f0c 100644 --- a/test/e2e/bind/happy-case_test.go +++ b/test/e2e/bind/happy-case_test.go @@ -19,11 +19,11 @@ package bind import ( "context" "fmt" + "path" "strings" "testing" "time" - "github.com/headzoo/surf" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" @@ -37,6 +37,7 @@ import ( "sigs.k8s.io/yaml" kuberesources "github.com/kube-bind/kube-bind/backend/kubernetes/resources" + bindapiservice "github.com/kube-bind/kube-bind/cli/pkg/kubectl/bind-apiservice/plugin" clusterscoped "github.com/kube-bind/kube-bind/pkg/konnector/controllers/cluster/serviceexport/cluster-scoped" kubebindv1alpha2 "github.com/kube-bind/kube-bind/sdk/apis/kubebind/v1alpha2" providerfixtures "github.com/kube-bind/kube-bind/test/e2e/bind/fixtures/provider" @@ -89,6 +90,10 @@ func testHappyCase( if resourceScope == apiextensionsv1.ClusterScoped { serviceGVR = schema.GroupVersionResource{Group: "bar.io", Version: "v1alpha1", Resource: "foos"} } + templateRef := "mangodb" + if resourceScope == apiextensionsv1.ClusterScoped { + templateRef = "foo" + } consumerClient := framework.DynamicClient(t, consumerConfig).Resource(serviceGVR) providerClient := framework.DynamicClient(t, providerConfig).Resource(serviceGVR) @@ -117,33 +122,73 @@ spec: consumerNS, providerNS := "default", "unknown" clusterNs, clusterScopedUpInsName := "unknown", "unknown" + kubeBindConfig := path.Join(framework.WorkDir, "kube-bind-config.yaml") + + // binding step outputs this. + var bindResponse *kubebindv1alpha2.BindingResourceResponse for _, tc := range []struct { name string step func(t *testing.T) }{ { - name: "Service is bound dry run", + name: "Login to provider", step: func(t *testing.T) { - iostreams, _, bufOut, _ := genericclioptions.NewTestIOStreams() + iostreams, _, _, _ := genericclioptions.NewTestIOStreams() authURLDryRunCh := make(chan string, 1) - go simulateBrowser(t, authURLDryRunCh, serviceGVR.Resource) - framework.Bind(t, iostreams, authURLDryRunCh, nil, fmt.Sprintf("http://%s/exports", addr.String()), "--kubeconfig", consumerKubeconfig, "--skip-konnector", "--dry-run") - _, err := yaml.YAMLToJSON(bufOut.Bytes()) + go framework.SimulateBrowser(t, authURLDryRunCh) + framework.Login(t, iostreams, authURLDryRunCh, kubeBindConfig, fmt.Sprintf("http://%s/api/exports", addr.String()), "") + }, + }, + { + name: "List templates", + step: func(t *testing.T) { + c := framework.GetKubeBindRestClient(t, kubeBindConfig) + result, err := c.GetTemplates(ctx) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, result.Items, 2) + }, + }, + { + name: "List collections", + step: func(t *testing.T) { + c := framework.GetKubeBindRestClient(t, kubeBindConfig) + result, err := c.GetCollections(ctx) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, result.Items, 1) + }, + }, + { + name: "Get bind APIServiceExportRequest from server", + step: func(t *testing.T) { + c := framework.GetKubeBindRestClient(t, kubeBindConfig) + var err error + bindResponse, err = c.Bind(ctx, &kubebindv1alpha2.BindableResourcesRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-binding", + }, + TemplateRef: kubebindv1alpha2.APIServiceExportTemplateRef{ + Name: templateRef, + }, + }) require.NoError(t, err) + require.NotNil(t, bindResponse) }, }, { - name: "Service is bound", + name: "Bind the payload", step: func(t *testing.T) { iostreams, _, _, _ := genericclioptions.NewTestIOStreams() - authURLCh := make(chan string, 1) - go simulateBrowser(t, authURLCh, serviceGVR.Resource) - invocations := make(chan framework.SubCommandInvocation, 1) - framework.Bind(t, iostreams, authURLCh, invocations, fmt.Sprintf("http://%s/exports", addr.String()), "--kubeconfig", consumerKubeconfig, "--skip-konnector") - inv := <-invocations - requireEqualSlicePattern(t, []string{"apiservice", "--remote-kubeconfig-namespace", "*", "--remote-kubeconfig-name", "*", "-f", "-", "--kubeconfig=" + consumerKubeconfig, "--skip-konnector=true", "--no-banner"}, inv.Args) + binderOpts := &bindapiservice.BinderOptions{ + IOStreams: iostreams, + SkipKonnector: true, + } - framework.BindAPIService(t, inv.Stdin, "", inv.Args...) + binder := bindapiservice.NewBinder(consumerConfig, binderOpts) + result, err := binder.BindFromResponse(ctx, bindResponse) + require.NoError(t, err) + require.Len(t, result, 1) t.Logf("Waiting for %s CRD to be created on consumer side", serviceGVR.Resource) crdClient := framework.ApiextensionsClient(t, consumerConfig).ApiextensionsV1().CustomResourceDefinitions() @@ -717,19 +762,6 @@ spec: }, wait.ForeverTestTimeout, time.Millisecond*100, "waiting for the %s instance to be deleted on provider side", serviceGVR.Resource) }, }, - { - name: "Bind again", - step: func(t *testing.T) { - iostreams, _, _, _ := genericclioptions.NewTestIOStreams() - authURLCh := make(chan string, 1) - go simulateBrowser(t, authURLCh, serviceGVR.Resource) - invocations := make(chan framework.SubCommandInvocation, 1) - framework.Bind(t, iostreams, authURLCh, invocations, fmt.Sprintf("http://%s/exports", addr.String()), "--kubeconfig", consumerKubeconfig, "--skip-konnector") - inv := <-invocations - requireEqualSlicePattern(t, []string{"apiservice", "--remote-kubeconfig-namespace", "*", "--remote-kubeconfig-name", "*", "-f", "-", "--kubeconfig=" + consumerKubeconfig, "--skip-konnector=true", "--no-banner"}, inv.Args) - framework.BindAPIService(t, inv.Stdin, "", inv.Args...) - }, - }, } { t.Run(tc.name, func(t *testing.T) { tc.step(t) @@ -737,34 +769,6 @@ spec: } } -func simulateBrowser(t *testing.T, authURLCh chan string, resource string) { - browser := surf.NewBrowser() - authURL := <-authURLCh - - t.Logf("Browsing to auth URL: %s", authURL) - err := browser.Open(authURL) - require.NoError(t, err) - - t.Logf("Waiting for browser to be at /resources") - framework.BrowserEventuallyAtPath(t, browser, "/resources") - - // Convert resource name to template name - var templateName string - switch resource { - case "mangodbs": - templateName = "mangodb" - case "foos": - templateName = "foo" - } - - t.Logf("Clicking template %s", templateName) - err = browser.Click("a." + templateName) - require.NoError(t, err) - - t.Logf("Waiting for browser to be forwarded to client") - framework.BrowserEventuallyAtPath(t, browser, "/callback") -} - func toUnstructured(t *testing.T, manifest string) *unstructured.Unstructured { t.Helper() @@ -774,16 +778,3 @@ func toUnstructured(t *testing.T, manifest string) *unstructured.Unstructured { return &unstructured.Unstructured{Object: obj} } - -func requireEqualSlicePattern(t *testing.T, pattern []string, slice []string) { - t.Helper() - - require.Equal(t, len(pattern), len(slice), "slice length doesn't match pattern length\n got: %s\nexpected: %s", strings.Join(slice, " "), strings.Join(pattern, " ")) - - for i, s := range slice { - if pattern[i] == "*" { - continue - } - require.Equal(t, pattern[i], s, "slice doesn't match pattern at index %d\n got: %s\nexpected: %s", i, strings.Join(slice, " "), strings.Join(pattern, " ")) - } -} diff --git a/test/e2e/framework/apply.go b/test/e2e/framework/apply.go index 9795e384f..fe8165c0e 100644 --- a/test/e2e/framework/apply.go +++ b/test/e2e/framework/apply.go @@ -88,6 +88,11 @@ func ApplyManifest(t testing.TB, config *rest.Config, manifests ...any) { err := yaml.Unmarshal([]byte(m), &obj) require.NoError(t, err, "Failed to unmarshal manifest") u.Object = obj + case []byte: + obj := map[string]any{} + err := yaml.Unmarshal(m, &obj) + require.NoError(t, err, "Failed to unmarshal manifest") + u.Object = obj case yaml.Node: obj := map[string]any{} err := m.Decode(&obj) diff --git a/test/e2e/framework/backend.go b/test/e2e/framework/backend.go index 2e0f4fc6b..ba585e44e 100644 --- a/test/e2e/framework/backend.go +++ b/test/e2e/framework/backend.go @@ -83,7 +83,7 @@ func StartBackendWithoutDefaultArgs(t testing.TB, args ...string) (net.Addr, *ba dexId, dexSecret := CreateDexClient(t, addr) opts.OIDC.IssuerClientID = dexId opts.OIDC.IssuerClientSecret = dexSecret - opts.OIDC.CallbackURL = "http://" + addr.String() + "/callback" + opts.OIDC.CallbackURL = "http://" + addr.String() + "/api/callback" // Skip name conflict validation - when run in-process with multiple // controllers they all will register the same metric names, which diff --git a/test/e2e/framework/bind.go b/test/e2e/framework/bind.go index 86c126f6f..d864f5ca5 100644 --- a/test/e2e/framework/bind.go +++ b/test/e2e/framework/bind.go @@ -17,89 +17,58 @@ limitations under the License. package framework import ( - "bytes" "context" - "io" - "os" - "os/exec" - "strings" "testing" "github.com/spf13/cobra" "github.com/stretchr/testify/require" "k8s.io/cli-runtime/pkg/genericclioptions" - bindapiserviceplugin "github.com/kube-bind/kube-bind/cli/pkg/kubectl/bind-apiservice/plugin" - bindplugin "github.com/kube-bind/kube-bind/cli/pkg/kubectl/bind/plugin" + "github.com/kube-bind/kube-bind/cli/pkg/client" + "github.com/kube-bind/kube-bind/cli/pkg/config" + loginplugin "github.com/kube-bind/kube-bind/cli/pkg/kubectl/bind-login/plugin" ) -func Bind(t *testing.T, iostreams genericclioptions.IOStreams, authURLCh chan<- string, invocations chan<- SubCommandInvocation, positionalArg string, flags ...string) { - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) +func GetKubeBindRestClient(t *testing.T, configFile string) client.Client { + t.Helper() - args := flags - if positionalArg != "" { - args = append(args, positionalArg) - } - t.Logf("kubectl bind %s", strings.Join(args, " ")) - - opts := bindplugin.NewBindOptions(iostreams) - cmd := &cobra.Command{} - opts.AddCmdFlags(cmd) - err := cmd.Flags().Parse(flags) + c, err := config.LoadConfigFromFile(configFile) require.NoError(t, err) - err = opts.Complete([]string{positionalArg}) - require.NoError(t, err) - err = opts.Validate() + server, _, err := c.GetCurrentServer() require.NoError(t, err) - opts.Runner = func(cmd *exec.Cmd) error { - bs, err := io.ReadAll(cmd.Stdin) - if err != nil { - return err - } - if invocations != nil { - invocations <- SubCommandInvocation{ - Executable: cmd.Args[0], - Args: cmd.Args[1:], - Stdin: bs, - } - } - t.Logf("Running command: %s\nstdin:\n", cmd.String()) - t.Logf("%s", bs) - - return nil - } - err = opts.Run(ctx, authURLCh) + require.NotNil(t, server, "no current server configured in %s", configFile) + + client, err := client.NewClient(*server, client.WithInsecure(true)) require.NoError(t, err) -} -type SubCommandInvocation struct { - Executable string - Args []string - Stdin []byte + return client } -func BindAPIService(t *testing.T, stdin []byte, positionalArg string, flags ...string) { +func Login(t *testing.T, iostreams genericclioptions.IOStreams, authURLCh chan<- string, configFile, serverURL, clusterID string) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - args := flags - if positionalArg != "" { - args = append(args, positionalArg) - } - t.Logf("kubectl bind apiservice %s", strings.Join(args, " ")) + t.Logf("kubectl login %s --skip-browser --config-file=%s --cluster=%s", serverURL, configFile, clusterID) - opts := bindapiserviceplugin.NewBindAPIServiceOptions(genericclioptions.IOStreams{In: bytes.NewReader(stdin), Out: os.Stdout, ErrOut: os.Stderr}) + opts := loginplugin.NewLoginOptions(iostreams) cmd := &cobra.Command{} opts.AddCmdFlags(cmd) - err := cmd.Flags().Parse(flags) + args := []string{serverURL} + if clusterID != "" { + args = append(args, "--cluster="+clusterID) + } + if configFile != "" { + args = append(args, "--config-file="+configFile) + } + err := cmd.Flags().Parse(args) require.NoError(t, err) - err = opts.Complete([]string{positionalArg}) + err = opts.Complete(args) require.NoError(t, err) + err = opts.Validate() require.NoError(t, err) - err = opts.Run(ctx) + err = opts.Run(ctx, authURLCh) require.NoError(t, err) } diff --git a/test/e2e/framework/browser.go b/test/e2e/framework/browser.go index 6772b22ab..f71c5a47f 100644 --- a/test/e2e/framework/browser.go +++ b/test/e2e/framework/browser.go @@ -21,6 +21,7 @@ import ( "testing" "time" + "github.com/headzoo/surf" "github.com/headzoo/surf/browser" "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/util/wait" @@ -36,3 +37,12 @@ func BrowserEventuallyAtPath(t *testing.T, browser *browser.Browser, path string return false }, wait.ForeverTestTimeout, time.Millisecond*100, "Browser is not at path %s", path) } + +func SimulateBrowser(t *testing.T, authURLCh chan string) { + browser := surf.NewBrowser() + authURL := <-authURLCh + + t.Logf("Browsing to auth URL: %s", authURL) + err := browser.Open(authURL) + require.NoError(t, err) +} diff --git a/test/e2e/framework/dex.go b/test/e2e/framework/dex.go index 10138164c..a5a32e3e4 100644 --- a/test/e2e/framework/dex.go +++ b/test/e2e/framework/dex.go @@ -61,8 +61,13 @@ func StartDex(t testing.TB) { t.Logf("Starting dex with config %q", dexConfig) + dexBinary := os.Getenv("DEX_BINARY") + if dexBinary == "" { + dexBinary = "dex" + } + dexCmd := exec.Command( - "dex", + dexBinary, "serve", dexConfig, ) @@ -104,7 +109,7 @@ func CreateDexClient(t testing.TB, addr net.Addr) (string, string) { Client: &dexapi.Client{ Id: id, Secret: secret, - RedirectUris: []string{fmt.Sprintf("http://%s/callback", addr)}, + RedirectUris: []string{fmt.Sprintf("http://%s/api/callback", addr)}, Public: true, Name: "kube-bind on port " + port, }, diff --git a/test/e2e/framework/kcp.go b/test/e2e/framework/kcp.go index d89c8a3a4..f9945834c 100644 --- a/test/e2e/framework/kcp.go +++ b/test/e2e/framework/kcp.go @@ -63,6 +63,13 @@ func WithName(s string, formatArgs ...any) ClusterWorkspaceOption { } } +func WithStaticName(s string) ClusterWorkspaceOption { + return func(ws *tenancyv1alpha1.Workspace) { + ws.Name = s + ws.GenerateName = "" + } +} + func RandomString(length int) string { token := make([]byte, length) rand.Read(token) //nolint:errcheck diff --git a/web/.eslintrc.cjs b/web/.eslintrc.cjs new file mode 100644 index 000000000..55117e7cc --- /dev/null +++ b/web/.eslintrc.cjs @@ -0,0 +1,17 @@ +/* eslint-env node */ +require('@rushstack/eslint-patch/modern-module-resolution') + +module.exports = { + root: true, + 'extends': [ + 'plugin:vue/vue3-essential', + 'eslint:recommended', + '@vue/eslint-config-typescript' + ], + parserOptions: { + ecmaVersion: 'latest' + }, + rules: { + 'vue/multi-word-component-names': 'off' + } +} \ No newline at end of file diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 000000000..1a7f3efcd --- /dev/null +++ b/web/.npmrc @@ -0,0 +1,3 @@ +optional=false +fund=false +audit=false \ No newline at end of file diff --git a/web/README.md b/web/README.md new file mode 100644 index 000000000..654c3b68c --- /dev/null +++ b/web/README.md @@ -0,0 +1,104 @@ +# Kube Bind Frontend + +A Vue.js + TypeScript frontend application for the Kube Bind project that provides a web interface for binding Kubernetes resources across clusters with SSO authentication. + +## Features + +- **SSO Authentication**: OAuth2/OIDC-based authentication via `/api/authorize` endpoint +- **Resource Management**: Browse and bind available Kubernetes resources +- **Modern Stack**: Built with Vue.js 3, TypeScript, and Vite + +## Architecture + +The frontend integrates with the existing Go backend and provides compatibility for CLI clients through redirect handling: + +### API Endpoints + +- `/api/authorize` - SSO authentication endpoint +- `/api/callback` - OAuth2 callback handler +- `/api/resources` - Fetch available resources +- `/api/bind` - Bind resources to cluster +- `/api/clusters/{cluster}/resources` - Cluster-specific resources +- `/api/clusters/{cluster}/authorize` - Cluster-specific authentication +- `/api/exports` - Export binding configuration +- `/api/clusters/{cluster}/exports` - Cluster-specific exports + +### Redirect Handling + +For CLI compatibility, the following routes provide HTTP 302 redirects: + +- `/exports` → `/api/exports` +- `/clusters/{cluster}/exports` → `/api/clusters/{cluster}/exports` + +Frontend routes handle browser-based redirects for: + +- `/authorize` → `/api/authorize` +- `/clusters/{cluster}/authorize` → `/api/clusters/{cluster}/authorize` +- `/callback` → `/api/callback` + +## Development Setup + +### Prerequisites + +- Node.js 18+ and npm +- Go 1.19+ for running the backend server + +### Development Workflow + +#### Development with Hot Reload (Recommended) +```bash +# Terminal 1: Start Go backend +go run ./cmd/backend --listen-port=8080 --frontend http://localhost:3000 + +# Terminal 2: Start frontend dev server with hot reload +cd web +npm install +npm run dev + +### Available Scripts + +- `npm run dev` - Start development server +- `npm run build` - Build for production +- `npm run preview` - Preview production build +- `npm run lint` - Lint code +- `npm run type-check` - Run TypeScript type checking + +## Authentication Flow + +1. User clicks "Login" or accesses protected resource +2. Frontend redirects to `/api/authorize` with session parameters +3. Backend handles OAuth2 flow with configured OIDC provider +4. User is redirected back to frontend with authentication cookie +5. Frontend can now access protected endpoints + +## Project Structure + +```text +src/ +├── main.ts # Application entry point and routing +├── App.vue # Root component +├── services/ +│ └── auth.ts # Authentication and API service +└── views/ + └── Resources.vue # Resource management interface +``` + +## Configuration + +The frontend automatically detects the backend API through Vite proxy configuration. For production deployments, ensure the frontend is served from the same domain as the backend or configure CORS appropriately. + +## Building for Production + +### Integrated Build +```bash +# Use the build script (builds frontend + Go binary) +./scripts/build-frontend.sh +``` + +### Frontend Only +```bash +cd web +npm run build +``` + +The built files will be in the `web/dist/` directory and are automatically embedded into the container image and served from there. \ No newline at end of file diff --git a/web/dist/assets/index.33c2e7b8.css b/web/dist/assets/index.33c2e7b8.css new file mode 100644 index 000000000..468034a76 --- /dev/null +++ b/web/dist/assets/index.33c2e7b8.css @@ -0,0 +1,832 @@ + +#app[data-v-393414b1] { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; +} +.header[data-v-393414b1] { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + position: sticky; + top: 0; + z-index: 100; +} +.header-content[data-v-393414b1] { + max-width: 1200px; + margin: 0 auto; + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 2rem; +} +.brand[data-v-393414b1] { + display: flex; + align-items: center; + gap: 0.75rem; +} +.logo[data-v-393414b1] { + color: rgba(255, 255, 255, 0.9); + display: flex; + align-items: center; + justify-content: center; +} +.header h1[data-v-393414b1] { + margin: 0; + color: white; + font-size: 1.5rem; + font-weight: 600; + letter-spacing: -0.025em; +} +.user-section[data-v-393414b1] { + display: flex; + align-items: center; + gap: 1.5rem; +} +.user-info[data-v-393414b1] { + display: flex; + align-items: center; + gap: 0.5rem; + color: rgba(255, 255, 255, 0.9); + font-size: 0.875rem; + font-weight: 500; +} +.status-indicator[data-v-393414b1] { + width: 8px; + height: 8px; + background: #10b981; + border-radius: 50%; + box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.3); + animation: pulse-393414b1 2s infinite; +} +@keyframes pulse-393414b1 { +0%, 100% { + opacity: 1; +} +50% { + opacity: 0.5; +} +} +.welcome-text[data-v-393414b1] { + color: rgba(255, 255, 255, 0.8); +} +.logout-btn[data-v-393414b1] { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; + backdrop-filter: blur(10px); +} +.logout-btn[data-v-393414b1]:hover { + background: rgba(255, 255, 255, 0.2); + color: white; + border-color: rgba(255, 255, 255, 0.3); + transform: translateY(-1px); +} +.main[data-v-393414b1] { + min-height: calc(100vh - 80px); + background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); + padding: 0; +} +.auth-placeholder[data-v-393414b1] { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 4rem 2rem; + min-height: calc(100vh - 200px); +} +.auth-placeholder h2[data-v-393414b1] { + color: #1f2937; + margin-bottom: 1rem; + font-size: 2rem; + font-weight: 600; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} +.auth-placeholder p[data-v-393414b1] { + color: #6b7280; + margin-bottom: 2rem; + font-size: 1.125rem; + max-width: 500px; + line-height: 1.6; +} +.auth-btn[data-v-393414b1] { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 1rem 2rem; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + border-radius: 12px; + cursor: pointer; + font-size: 1rem; + font-weight: 600; + transition: all 0.2s ease; + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); +} +.auth-btn[data-v-393414b1]:hover { + background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%); + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4); +} + +.binding-modal-overlay[data-v-e150bd4a] { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; +} +.binding-modal[data-v-e150bd4a] { + background: white; + border-radius: 12px; + width: 90%; + max-width: 900px; + max-height: 85vh; + overflow: hidden; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); +} +.binding-header[data-v-e150bd4a] { + 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%); + color: white; +} +.binding-header h3[data-v-e150bd4a] { + margin: 0; + font-size: 1.25rem; + font-weight: 600; +} +.close-btn[data-v-e150bd4a] { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: rgba(255, 255, 255, 0.8); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s; +} +.close-btn[data-v-e150bd4a]:hover { + background: rgba(255, 255, 255, 0.1); + color: white; +} +.binding-content[data-v-e150bd4a] { + padding: 2rem; + max-height: 70vh; + overflow-y: auto; +} +.binding-info[data-v-e150bd4a] { + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid #e5e7eb; +} +.binding-info h4[data-v-e150bd4a] { + margin-bottom: 1rem; + color: #111827; + font-size: 1.125rem; + font-weight: 600; +} +.binding-info p[data-v-e150bd4a] { + margin: 0.5rem 0; + color: #6b7280; + font-size: 0.9rem; +} +.instructions-section[data-v-e150bd4a] { + margin-bottom: 2rem; +} +.instructions-section h4[data-v-e150bd4a] { + margin-bottom: 1rem; + color: #111827; + font-size: 1.125rem; + font-weight: 600; +} +.instructions-text[data-v-e150bd4a] { + 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; +} +.download-files-section h5[data-v-e150bd4a] { + margin-bottom: 0.75rem; + color: #374151; + font-size: 1rem; + font-weight: 600; +} +.download-block[data-v-e150bd4a] { + background: #f0f9ff; + border: 1px solid #bfdbfe; + border-radius: 8px; + padding: 1rem; +} +.download-text[data-v-e150bd4a] { + margin-bottom: 1rem; + color: #374151; + font-size: 0.875rem; +} +.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; +} +.command-group[data-v-e150bd4a] { + margin-bottom: 1.5rem; +} +.command-group h5[data-v-e150bd4a] { + margin-bottom: 0.75rem; + color: #374151; + font-size: 1rem; + font-weight: 600; +} +.command-block[data-v-e150bd4a] { + display: flex; + align-items: center; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 1rem; + gap: 1rem; +} +.command-block code[data-v-e150bd4a] { + flex: 1; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', monospace; + font-size: 0.875rem; + color: #1f2937; + background: none; + word-break: break-all; +} +.copy-cmd-btn[data-v-e150bd4a] { + padding: 0.5rem 1rem; + background: #3b82f6; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: background-color 0.2s; + flex-shrink: 0; +} +.copy-cmd-btn[data-v-e150bd4a]:hover { + background: #2563eb; +} +.alternative-section[data-v-e150bd4a] { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid #e5e7eb; +} +.alternative-section details[data-v-e150bd4a] { + cursor: pointer; +} +.alternative-section summary[data-v-e150bd4a] { + font-weight: 600; + color: #6b7280; + padding: 0.5rem 0; + outline: none; +} +.manual-setup[data-v-e150bd4a] { + padding: 1rem 0; +} +.manual-setup h5[data-v-e150bd4a] { + margin-bottom: 1rem; + color: #374151; + font-weight: 600; +} +.download-actions[data-v-e150bd4a] { + display: flex; + gap: 1rem; +} +.download-btn[data-v-e150bd4a] { + 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-btn[data-v-e150bd4a]:hover { + background: #4b5563; +} +.binding-footer[data-v-e150bd4a] { + padding: 1.5rem 2rem; + border-top: 1px solid #e5e7eb; + background: #f9fafb; + text-align: right; +} +.ok-btn[data-v-e150bd4a] { + padding: 0.75rem 1.5rem; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + transition: all 0.2s; +} +.ok-btn[data-v-e150bd4a]:hover { + background: linear-gradient(135deg, #059669 0%, #047857 100%); + transform: translateY(-1px); +} + +.modal-overlay[data-v-28ea2ebf] { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn-28ea2ebf 0.2s ease; +} +.modal[data-v-28ea2ebf] { + background: white; + border-radius: 12px; + width: 90%; + max-width: 700px; + max-height: 85vh; + overflow: hidden; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); + animation: slideIn-28ea2ebf 0.3s ease; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; +} +@keyframes fadeIn-28ea2ebf { +from { opacity: 0; +} +to { opacity: 1; +} +} +@keyframes slideIn-28ea2ebf { +from { transform: translateY(-20px) scale(0.95); opacity: 0; +} +to { transform: translateY(0) scale(1); opacity: 1; +} +} +.modal-header[data-v-28ea2ebf] { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem 2rem; + border-bottom: 1px solid #e5e7eb; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} +.modal-header h3[data-v-28ea2ebf] { + margin: 0; + font-size: 1.25rem; + font-weight: 600; +} +.close-btn[data-v-28ea2ebf] { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: rgba(255, 255, 255, 0.8); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s; +} +.close-btn[data-v-28ea2ebf]:hover { + background: rgba(255, 255, 255, 0.1); + color: white; +} +.modal-content[data-v-28ea2ebf] { + padding: 2rem; + max-height: 60vh; + overflow-y: auto; +} +.binding-name-section[data-v-28ea2ebf] { + margin-bottom: 2rem; + padding-bottom: 2rem; + border-bottom: 1px solid #e5e7eb; +} +.form-label[data-v-28ea2ebf] { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: #374151; +} +.form-input[data-v-28ea2ebf] { + width: 100%; + padding: 0.75rem 1rem; + border: 2px solid #d1d5db; + border-radius: 8px; + font-size: 1rem; + transition: border-color 0.2s, box-shadow 0.2s; +} +.form-input[data-v-28ea2ebf]:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} +.form-help[data-v-28ea2ebf] { + margin-top: 0.5rem; + font-size: 0.875rem; + color: #6b7280; +} +.form-input.invalid[data-v-28ea2ebf] { + border-color: #dc2626; + background-color: #fef2f2; +} +.form-input.invalid[data-v-28ea2ebf]:focus { + border-color: #dc2626; + box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1); +} +.form-error[data-v-28ea2ebf] { + margin-top: 0.5rem; + font-size: 0.875rem; + color: #dc2626; + font-weight: 500; +} +.template-details h4[data-v-28ea2ebf] { + margin-bottom: 1.5rem; + color: #111827; + font-size: 1.125rem; + font-weight: 600; +} +.detail-section[data-v-28ea2ebf] { + margin-bottom: 2rem; +} +.detail-section h5[data-v-28ea2ebf] { + margin-bottom: 1rem; + color: #374151; + font-size: 1rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; +} +.description[data-v-28ea2ebf] { + color: #6b7280; + line-height: 1.6; + background: #f9fafb; + padding: 1rem; + border-radius: 8px; + border-left: 4px solid #667eea; +} +.resource-list[data-v-28ea2ebf], .permission-list[data-v-28ea2ebf], .namespace-list[data-v-28ea2ebf] { + display: grid; + gap: 0.75rem; +} +.resource-item[data-v-28ea2ebf], .permission-item[data-v-28ea2ebf], .namespace-item[data-v-28ea2ebf] { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 1rem; + transition: border-color 0.2s; +} +.resource-item[data-v-28ea2ebf]:hover, .permission-item[data-v-28ea2ebf]:hover, .namespace-item[data-v-28ea2ebf]:hover { + border-color: #cbd5e1; +} +.resource-name[data-v-28ea2ebf], .permission-name[data-v-28ea2ebf], .namespace-name[data-v-28ea2ebf] { + font-weight: 600; + color: #1e293b; + display: block; + margin-bottom: 0.25rem; +} +.resource-group[data-v-28ea2ebf], .permission-group[data-v-28ea2ebf] { + font-size: 0.875rem; + color: #64748b; + background: #e2e8f0; + padding: 0.25rem 0.5rem; + border-radius: 4px; + display: inline-block; + margin-right: 0.5rem; +} +.resource-versions[data-v-28ea2ebf] { + font-size: 0.875rem; + color: #059669; + background: #d1fae5; + padding: 0.25rem 0.5rem; + border-radius: 4px; + display: inline-block; +} +.permission-selector[data-v-28ea2ebf] { + margin-top: 0.5rem; + font-size: 0.875rem; +} +.selector-labels[data-v-28ea2ebf], .selector-names[data-v-28ea2ebf] { + display: block; + color: #6b7280; + margin-bottom: 0.25rem; +} +.namespace-desc[data-v-28ea2ebf] { + font-size: 0.875rem; + color: #6b7280; + display: block; + margin-top: 0.25rem; +} +.modal-footer[data-v-28ea2ebf] { + padding: 1.5rem 2rem; + border-top: 1px solid #e5e7eb; + background: #f9fafb; + display: flex; + justify-content: flex-end; + gap: 1rem; +} +.cancel-btn[data-v-28ea2ebf], .bind-btn[data-v-28ea2ebf] { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + transition: all 0.2s; +} +.cancel-btn[data-v-28ea2ebf] { + background: #f3f4f6; + color: #374151; +} +.cancel-btn[data-v-28ea2ebf]:hover { + background: #e5e7eb; +} +.bind-btn[data-v-28ea2ebf] { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; +} +.bind-btn[data-v-28ea2ebf]:hover:not(:disabled) { + background: linear-gradient(135deg, #059669 0%, #047857 100%); + transform: translateY(-1px); +} +.bind-btn[data-v-28ea2ebf]:disabled { + background: #d1d5db; + color: #9ca3af; + cursor: not-allowed; + transform: none; +} + +.resources[data-v-1cfa8d3f] { + 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-1cfa8d3f] { + margin-bottom: 2rem; +} +.resources h2[data-v-1cfa8d3f] { + color: #1e293b; + margin-bottom: 1rem; + font-size: 1.875rem; + font-weight: 600; + font-family: inherit; +} +.resources h3[data-v-1cfa8d3f] { + color: #334155; + font-size: 1.25rem; + font-weight: 600; + font-family: inherit; + margin: 0; +} +.cli-indicator[data-v-1cfa8d3f] { + background-color: #dbeafe; + border: 1px solid #3b82f6; + border-radius: 8px; + padding: 0.75rem 1rem; + color: #1e40af; + font-weight: 500; + font-size: 0.875rem; + display: flex; + align-items: center; + gap: 0.5rem; +} +.loading[data-v-1cfa8d3f] { + text-align: center; + padding: 2rem; + color: #666; +} +.error[data-v-1cfa8d3f] { + text-align: center; + padding: 2rem; + color: #dc3545; +} +.retry-btn[data-v-1cfa8d3f] { + padding: 0.5rem 1rem; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + margin-top: 1rem; +} +.retry-btn[data-v-1cfa8d3f]:hover { + background-color: #0056b3; +} +.resources-container[data-v-1cfa8d3f] { + display: grid; + gap: 3rem; +} +.templates-section[data-v-1cfa8d3f], +.collections-section[data-v-1cfa8d3f] { + background: #f8fafc; + padding: 1.5rem; + border-radius: 12px; + border: 1px solid #e2e8f0; +} +.no-resources[data-v-1cfa8d3f] { + text-align: center; + color: #666; + padding: 1rem; +} +.section-header[data-v-1cfa8d3f] { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} +.item-count[data-v-1cfa8d3f] { + font-size: 0.9rem; + color: #666; + background: #e9ecef; + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-weight: 500; +} +.no-resources[data-v-1cfa8d3f] { + text-align: center; + padding: 3rem 2rem; + color: #6b7280; + background: white; + border-radius: 12px; + border: 2px dashed #d1d5db; +} +.no-resources-icon[data-v-1cfa8d3f] { + margin-bottom: 1rem; + opacity: 0.4; + color: #9ca3af; +} +.no-resources h4[data-v-1cfa8d3f] { + margin: 0 0 0.5rem 0; + color: #374151; + font-weight: 600; +} +.no-resources p[data-v-1cfa8d3f] { + margin: 0; + font-size: 0.9rem; +} +.resource-grid[data-v-1cfa8d3f] { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); + gap: 1.5rem; +} +.template-card[data-v-1cfa8d3f], .collection-card[data-v-1cfa8d3f] { + background: white; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 0; + transition: all 0.2s ease; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); +} +.template-card[data-v-1cfa8d3f]:hover, .collection-card[data-v-1cfa8d3f]:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); + border-color: #cbd5e1; +} +.card-header[data-v-1cfa8d3f] { + padding: 1.5rem 1.5rem 1rem 1.5rem; + border-bottom: 1px solid #f1f5f9; +} +.card-title[data-v-1cfa8d3f] { + margin: 0 0 0.75rem 0; + color: #1e293b; + font-size: 1.125rem; + font-weight: 600; +} +.card-badges[data-v-1cfa8d3f] { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.badge[data-v-1cfa8d3f] { + font-size: 0.75rem; + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-weight: 500; + display: inline-flex; + align-items: center; +} +.resources-badge[data-v-1cfa8d3f] { + background: #dbeafe; + color: #1d4ed8; +} +.permissions-badge[data-v-1cfa8d3f] { + background: #fef3c7; + color: #d97706; +} +.namespaces-badge[data-v-1cfa8d3f] { + background: #d1fae5; + color: #047857; +} +.card-content[data-v-1cfa8d3f] { + padding: 1rem 1.5rem; +} +.card-description[data-v-1cfa8d3f] { + color: #64748b; + margin: 0 0 1rem 0; + line-height: 1.5; + font-size: 0.9rem; +} +.card-preview[data-v-1cfa8d3f], .collection-templates[data-v-1cfa8d3f] { + margin-top: 1rem; +} +.card-preview strong[data-v-1cfa8d3f], .collection-templates strong[data-v-1cfa8d3f] { + color: #374151; + font-size: 0.875rem; + display: block; + margin-bottom: 0.5rem; +} +.resource-preview[data-v-1cfa8d3f], .template-list[data-v-1cfa8d3f] { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} +.resource-tag[data-v-1cfa8d3f], .template-tag[data-v-1cfa8d3f] { + font-size: 0.75rem; + background: #f1f5f9; + color: #475569; + padding: 0.25rem 0.5rem; + border-radius: 6px; + border: 1px solid #e2e8f0; +} +.more-indicator[data-v-1cfa8d3f] { + font-size: 0.75rem; + color: #6b7280; + font-style: italic; +} +.card-actions[data-v-1cfa8d3f] { + padding: 1rem 1.5rem; + background: #fafbfc; + border-top: 1px solid #f1f5f9; + display: flex; + gap: 0.75rem; + justify-content: flex-end; +} +.details-btn[data-v-1cfa8d3f], .bind-btn[data-v-1cfa8d3f] { + padding: 0.5rem 1rem; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + font-size: 0.875rem; + transition: all 0.2s ease; +} +.details-btn[data-v-1cfa8d3f] { + background: #f8fafc; + color: #475569; + border: 1px solid #e2e8f0; +} +.details-btn[data-v-1cfa8d3f]:hover { + background: #f1f5f9; + border-color: #cbd5e1; +} +.bind-btn[data-v-1cfa8d3f] { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + border: 1px solid transparent; +} +.bind-btn[data-v-1cfa8d3f]: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.4caa6f6c.js b/web/dist/assets/index.4caa6f6c.js new file mode 100644 index 000000000..7142e62ec --- /dev/null +++ b/web/dist/assets/index.4caa6f6c.js @@ -0,0 +1,12157 @@ +var __defProp = Object.defineProperty; +var __defProps = Object.defineProperties; +var __getOwnPropDescs = Object.getOwnPropertyDescriptors; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getOwnPropSymbols = Object.getOwnPropertySymbols; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __propIsEnum = Object.prototype.propertyIsEnumerable; +var __knownSymbol = (name, symbol) => { + if (symbol = Symbol[name]) + return symbol; + throw Error("Symbol." + name + " is not defined"); +}; +var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; +var __spreadValues = (a, b) => { + for (var prop in b || (b = {})) + if (__hasOwnProp.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + if (__getOwnPropSymbols) + for (var prop of __getOwnPropSymbols(b)) { + if (__propIsEnum.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + } + return a; +}; +var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b)); +var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; +var __async = (__this, __arguments, generator) => { + return new Promise((resolve, reject) => { + var fulfilled = (value) => { + try { + step(generator.next(value)); + } catch (e) { + reject(e); + } + }; + var rejected = (value) => { + try { + step(generator.throw(value)); + } catch (e) { + reject(e); + } + }; + var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected); + step((generator = generator.apply(__this, __arguments)).next()); + }); +}; +var __await = function(promise, isYieldStar) { + this[0] = promise; + this[1] = isYieldStar; +}; +var __asyncGenerator = (__this, __arguments, generator) => { + var resume = (k, v, yes, no) => { + try { + var x = generator[k](v), isAwait = (v = x.value) instanceof __await, done = x.done; + Promise.resolve(isAwait ? v[0] : v).then((y) => isAwait ? resume(k === "return" ? k : "next", v[1] ? { done: y.done, value: y.value } : y, yes, no) : yes({ value: y, done })).catch((e) => resume("throw", e, yes, no)); + } catch (e) { + no(e); + } + }; + var method = (k) => it[k] = (x) => new Promise((yes, no) => resume(k, x, yes, no)); + var it = {}; + return generator = generator.apply(__this, __arguments), it[Symbol.asyncIterator] = () => it, method("next"), method("throw"), method("return"), it; +}; +var __yieldStar = (value) => { + var obj = value[__knownSymbol("asyncIterator")]; + var isAwait = false; + var method; + var it = {}; + if (obj == null) { + obj = value[__knownSymbol("iterator")](); + method = (k) => it[k] = (x) => obj[k](x); + } else { + obj = obj.call(value); + method = (k) => it[k] = (v) => { + if (isAwait) { + isAwait = false; + if (k === "throw") + throw v; + return v; + } + isAwait = true; + return { + done: false, + value: new __await(new Promise((resolve) => { + var x = obj[k](v); + if (!(x instanceof Object)) + throw TypeError("Object expected"); + resolve(x); + }), 1) + }; + }; + } + return it[__knownSymbol("iterator")] = () => it, method("next"), "throw" in obj ? method("throw") : it.throw = (x) => { + throw x; + }, "return" in obj && method("return"), it; +}; +var __forAwait = (obj, it, method) => (it = obj[__knownSymbol("asyncIterator")]) ? it.call(obj) : (obj = obj[__knownSymbol("iterator")](), it = {}, method = (key, fn) => (fn = obj[key]) && (it[key] = (arg) => new Promise((yes, no, done) => (arg = fn.call(obj, arg), done = arg.done, Promise.resolve(arg.value).then((value) => yes({ value, done }), no)))), method("next"), method("return"), it); +var require_index_001 = __commonJS({ + "assets/index.4caa6f6c.js"(exports) { + (function polyfill() { + const relList = document.createElement("link").relList; + if (relList && relList.supports && relList.supports("modulepreload")) { + return; + } + for (const link of document.querySelectorAll('link[rel="modulepreload"]')) { + processPreload(link); + } + new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type !== "childList") { + continue; + } + for (const node of mutation.addedNodes) { + if (node.tagName === "LINK" && node.rel === "modulepreload") + processPreload(node); + } + } + }).observe(document, { childList: true, subtree: true }); + function getFetchOpts(link) { + const fetchOpts = {}; + if (link.integrity) + fetchOpts.integrity = link.integrity; + if (link.referrerPolicy) + fetchOpts.referrerPolicy = link.referrerPolicy; + if (link.crossOrigin === "use-credentials") + fetchOpts.credentials = "include"; + else if (link.crossOrigin === "anonymous") + fetchOpts.credentials = "omit"; + else + fetchOpts.credentials = "same-origin"; + return fetchOpts; + } + function processPreload(link) { + if (link.ep) + return; + link.ep = true; + const fetchOpts = getFetchOpts(link); + fetch(link.href, fetchOpts); + } + })(); + /** + * @vue/shared v3.5.21 + * (c) 2018-present Yuxi (Evan) You and Vue contributors + * @license MIT + **/ + // @__NO_SIDE_EFFECTS__ + function makeMap(str) { + const map = /* @__PURE__ */ Object.create(null); + for (const key of str.split(",")) + map[key] = 1; + return (val) => val in map; + } + const EMPTY_OBJ = {}; + const EMPTY_ARR = []; + const NOOP = () => { + }; + const NO = () => false; + const isOn = (key) => key.charCodeAt(0) === 111 && key.charCodeAt(1) === 110 && // uppercase letter + (key.charCodeAt(2) > 122 || key.charCodeAt(2) < 97); + const isModelListener = (key) => key.startsWith("onUpdate:"); + const extend$1 = Object.assign; + const remove = (arr, el) => { + const i = arr.indexOf(el); + if (i > -1) { + arr.splice(i, 1); + } + }; + const hasOwnProperty$2 = Object.prototype.hasOwnProperty; + const hasOwn = (val, key) => hasOwnProperty$2.call(val, key); + const isArray$2 = Array.isArray; + const isMap = (val) => toTypeString(val) === "[object Map]"; + const isSet = (val) => toTypeString(val) === "[object Set]"; + const isFunction$2 = (val) => typeof val === "function"; + const isString$1 = (val) => typeof val === "string"; + const isSymbol = (val) => typeof val === "symbol"; + const isObject$1 = (val) => val !== null && typeof val === "object"; + const isPromise = (val) => { + return (isObject$1(val) || isFunction$2(val)) && isFunction$2(val.then) && isFunction$2(val.catch); + }; + const objectToString = Object.prototype.toString; + const toTypeString = (value) => objectToString.call(value); + const toRawType = (value) => { + return toTypeString(value).slice(8, -1); + }; + const isPlainObject$1 = (val) => toTypeString(val) === "[object Object]"; + const isIntegerKey = (key) => isString$1(key) && key !== "NaN" && key[0] !== "-" && "" + parseInt(key, 10) === key; + const isReservedProp = /* @__PURE__ */ makeMap( + // the leading comma is intentional so empty string "" is also included + ",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted" + ); + const cacheStringFunction = (fn) => { + const cache = /* @__PURE__ */ Object.create(null); + return (str) => { + const hit = cache[str]; + return hit || (cache[str] = fn(str)); + }; + }; + const camelizeRE = /-\w/g; + const camelize = cacheStringFunction( + (str) => { + return str.replace(camelizeRE, (c) => c.slice(1).toUpperCase()); + } + ); + const hyphenateRE = /\B([A-Z])/g; + const hyphenate = cacheStringFunction( + (str) => str.replace(hyphenateRE, "-$1").toLowerCase() + ); + const capitalize = cacheStringFunction((str) => { + return str.charAt(0).toUpperCase() + str.slice(1); + }); + const toHandlerKey = cacheStringFunction( + (str) => { + const s = str ? `on${capitalize(str)}` : ``; + return s; + } + ); + const hasChanged = (value, oldValue) => !Object.is(value, oldValue); + const invokeArrayFns = (fns, ...arg) => { + for (let i = 0; i < fns.length; i++) { + fns[i](...arg); + } + }; + const def = (obj, key, value, writable = false) => { + Object.defineProperty(obj, key, { + configurable: true, + enumerable: false, + writable, + value + }); + }; + const looseToNumber = (val) => { + const n = parseFloat(val); + return isNaN(n) ? val : n; + }; + let _globalThis; + const getGlobalThis = () => { + return _globalThis || (_globalThis = typeof globalThis !== "undefined" ? globalThis : typeof self !== "undefined" ? self : typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : {}); + }; + function normalizeStyle(value) { + if (isArray$2(value)) { + const res = {}; + for (let i = 0; i < value.length; i++) { + const item = value[i]; + const normalized = isString$1(item) ? parseStringStyle(item) : normalizeStyle(item); + if (normalized) { + for (const key in normalized) { + res[key] = normalized[key]; + } + } + } + return res; + } else if (isString$1(value) || isObject$1(value)) { + return value; + } + } + const listDelimiterRE = /;(?![^(]*\))/g; + const propertyDelimiterRE = /:([^]+)/; + const styleCommentRE = /\/\*[^]*?\*\//g; + function parseStringStyle(cssText) { + const ret = {}; + cssText.replace(styleCommentRE, "").split(listDelimiterRE).forEach((item) => { + if (item) { + const tmp = item.split(propertyDelimiterRE); + tmp.length > 1 && (ret[tmp[0].trim()] = tmp[1].trim()); + } + }); + return ret; + } + function normalizeClass(value) { + let res = ""; + if (isString$1(value)) { + res = value; + } else if (isArray$2(value)) { + for (let i = 0; i < value.length; i++) { + const normalized = normalizeClass(value[i]); + if (normalized) { + res += normalized + " "; + } + } + } else if (isObject$1(value)) { + for (const name in value) { + if (value[name]) { + res += name + " "; + } + } + } + return res.trim(); + } + const specialBooleanAttrs = `itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly`; + const isSpecialBooleanAttr = /* @__PURE__ */ makeMap(specialBooleanAttrs); + function includeBooleanAttr(value) { + return !!value || value === ""; + } + const isRef$1 = (val) => { + return !!(val && val["__v_isRef"] === true); + }; + const toDisplayString = (val) => { + return isString$1(val) ? val : val == null ? "" : isArray$2(val) || isObject$1(val) && (val.toString === objectToString || !isFunction$2(val.toString)) ? isRef$1(val) ? toDisplayString(val.value) : JSON.stringify(val, replacer, 2) : String(val); + }; + const replacer = (_key, val) => { + if (isRef$1(val)) { + return replacer(_key, val.value); + } else if (isMap(val)) { + return { + [`Map(${val.size})`]: [...val.entries()].reduce( + (entries, [key, val2], i) => { + entries[stringifySymbol(key, i) + " =>"] = val2; + return entries; + }, + {} + ) + }; + } else if (isSet(val)) { + return { + [`Set(${val.size})`]: [...val.values()].map((v) => stringifySymbol(v)) + }; + } else if (isSymbol(val)) { + return stringifySymbol(val); + } else if (isObject$1(val) && !isArray$2(val) && !isPlainObject$1(val)) { + return String(val); + } + return val; + }; + const stringifySymbol = (v, i = "") => { + var _a; + return ( + // Symbol.description in es2019+ so we need to cast here to pass + // the lib: es2016 check + isSymbol(v) ? `Symbol(${(_a = v.description) != null ? _a : i})` : v + ); + }; + /** + * @vue/reactivity v3.5.21 + * (c) 2018-present Yuxi (Evan) You and Vue contributors + * @license MIT + **/ + let activeEffectScope; + class EffectScope { + constructor(detached = false) { + this.detached = detached; + this._active = true; + this._on = 0; + this.effects = []; + this.cleanups = []; + this._isPaused = false; + this.parent = activeEffectScope; + if (!detached && activeEffectScope) { + this.index = (activeEffectScope.scopes || (activeEffectScope.scopes = [])).push( + this + ) - 1; + } + } + get active() { + return this._active; + } + pause() { + if (this._active) { + this._isPaused = true; + let i, l; + if (this.scopes) { + for (i = 0, l = this.scopes.length; i < l; i++) { + this.scopes[i].pause(); + } + } + for (i = 0, l = this.effects.length; i < l; i++) { + this.effects[i].pause(); + } + } + } + /** + * Resumes the effect scope, including all child scopes and effects. + */ + resume() { + if (this._active) { + if (this._isPaused) { + this._isPaused = false; + let i, l; + if (this.scopes) { + for (i = 0, l = this.scopes.length; i < l; i++) { + this.scopes[i].resume(); + } + } + for (i = 0, l = this.effects.length; i < l; i++) { + this.effects[i].resume(); + } + } + } + } + run(fn) { + if (this._active) { + const currentEffectScope = activeEffectScope; + try { + activeEffectScope = this; + return fn(); + } finally { + activeEffectScope = currentEffectScope; + } + } + } + /** + * This should only be called on non-detached scopes + * @internal + */ + on() { + if (++this._on === 1) { + this.prevScope = activeEffectScope; + activeEffectScope = this; + } + } + /** + * This should only be called on non-detached scopes + * @internal + */ + off() { + if (this._on > 0 && --this._on === 0) { + activeEffectScope = this.prevScope; + this.prevScope = void 0; + } + } + stop(fromParent) { + if (this._active) { + this._active = false; + let i, l; + for (i = 0, l = this.effects.length; i < l; i++) { + this.effects[i].stop(); + } + this.effects.length = 0; + for (i = 0, l = this.cleanups.length; i < l; i++) { + this.cleanups[i](); + } + this.cleanups.length = 0; + if (this.scopes) { + for (i = 0, l = this.scopes.length; i < l; i++) { + this.scopes[i].stop(true); + } + this.scopes.length = 0; + } + if (!this.detached && this.parent && !fromParent) { + const last = this.parent.scopes.pop(); + if (last && last !== this) { + this.parent.scopes[this.index] = last; + last.index = this.index; + } + } + this.parent = void 0; + } + } + } + function getCurrentScope() { + return activeEffectScope; + } + let activeSub; + const pausedQueueEffects = /* @__PURE__ */ new WeakSet(); + class ReactiveEffect { + constructor(fn) { + this.fn = fn; + this.deps = void 0; + this.depsTail = void 0; + this.flags = 1 | 4; + this.next = void 0; + this.cleanup = void 0; + this.scheduler = void 0; + if (activeEffectScope && activeEffectScope.active) { + activeEffectScope.effects.push(this); + } + } + pause() { + this.flags |= 64; + } + resume() { + if (this.flags & 64) { + this.flags &= -65; + if (pausedQueueEffects.has(this)) { + pausedQueueEffects.delete(this); + this.trigger(); + } + } + } + /** + * @internal + */ + notify() { + if (this.flags & 2 && !(this.flags & 32)) { + return; + } + if (!(this.flags & 8)) { + batch(this); + } + } + run() { + if (!(this.flags & 1)) { + return this.fn(); + } + this.flags |= 2; + cleanupEffect(this); + prepareDeps(this); + const prevEffect = activeSub; + const prevShouldTrack = shouldTrack; + activeSub = this; + shouldTrack = true; + try { + return this.fn(); + } finally { + cleanupDeps(this); + activeSub = prevEffect; + shouldTrack = prevShouldTrack; + this.flags &= -3; + } + } + stop() { + if (this.flags & 1) { + for (let link = this.deps; link; link = link.nextDep) { + removeSub(link); + } + this.deps = this.depsTail = void 0; + cleanupEffect(this); + this.onStop && this.onStop(); + this.flags &= -2; + } + } + trigger() { + if (this.flags & 64) { + pausedQueueEffects.add(this); + } else if (this.scheduler) { + this.scheduler(); + } else { + this.runIfDirty(); + } + } + /** + * @internal + */ + runIfDirty() { + if (isDirty(this)) { + this.run(); + } + } + get dirty() { + return isDirty(this); + } + } + let batchDepth = 0; + let batchedSub; + let batchedComputed; + function batch(sub, isComputed = false) { + sub.flags |= 8; + if (isComputed) { + sub.next = batchedComputed; + batchedComputed = sub; + return; + } + sub.next = batchedSub; + batchedSub = sub; + } + function startBatch() { + batchDepth++; + } + function endBatch() { + if (--batchDepth > 0) { + return; + } + if (batchedComputed) { + let e = batchedComputed; + batchedComputed = void 0; + while (e) { + const next = e.next; + e.next = void 0; + e.flags &= -9; + e = next; + } + } + let error; + while (batchedSub) { + let e = batchedSub; + batchedSub = void 0; + while (e) { + const next = e.next; + e.next = void 0; + e.flags &= -9; + if (e.flags & 1) { + try { + ; + e.trigger(); + } catch (err) { + if (!error) + error = err; + } + } + e = next; + } + } + if (error) + throw error; + } + function prepareDeps(sub) { + for (let link = sub.deps; link; link = link.nextDep) { + link.version = -1; + link.prevActiveLink = link.dep.activeLink; + link.dep.activeLink = link; + } + } + function cleanupDeps(sub) { + let head; + let tail = sub.depsTail; + let link = tail; + while (link) { + const prev = link.prevDep; + if (link.version === -1) { + if (link === tail) + tail = prev; + removeSub(link); + removeDep(link); + } else { + head = link; + } + link.dep.activeLink = link.prevActiveLink; + link.prevActiveLink = void 0; + link = prev; + } + sub.deps = head; + sub.depsTail = tail; + } + function isDirty(sub) { + for (let link = sub.deps; link; link = link.nextDep) { + if (link.dep.version !== link.version || link.dep.computed && (refreshComputed(link.dep.computed) || link.dep.version !== link.version)) { + return true; + } + } + if (sub._dirty) { + return true; + } + return false; + } + function refreshComputed(computed2) { + if (computed2.flags & 4 && !(computed2.flags & 16)) { + return; + } + computed2.flags &= -17; + if (computed2.globalVersion === globalVersion) { + return; + } + computed2.globalVersion = globalVersion; + if (!computed2.isSSR && computed2.flags & 128 && (!computed2.deps && !computed2._dirty || !isDirty(computed2))) { + return; + } + computed2.flags |= 2; + const dep = computed2.dep; + const prevSub = activeSub; + const prevShouldTrack = shouldTrack; + activeSub = computed2; + shouldTrack = true; + try { + prepareDeps(computed2); + const value = computed2.fn(computed2._value); + if (dep.version === 0 || hasChanged(value, computed2._value)) { + computed2.flags |= 128; + computed2._value = value; + dep.version++; + } + } catch (err) { + dep.version++; + throw err; + } finally { + activeSub = prevSub; + shouldTrack = prevShouldTrack; + cleanupDeps(computed2); + computed2.flags &= -3; + } + } + function removeSub(link, soft = false) { + const { dep, prevSub, nextSub } = link; + if (prevSub) { + prevSub.nextSub = nextSub; + link.prevSub = void 0; + } + if (nextSub) { + nextSub.prevSub = prevSub; + link.nextSub = void 0; + } + if (dep.subs === link) { + dep.subs = prevSub; + if (!prevSub && dep.computed) { + dep.computed.flags &= -5; + for (let l = dep.computed.deps; l; l = l.nextDep) { + removeSub(l, true); + } + } + } + if (!soft && !--dep.sc && dep.map) { + dep.map.delete(dep.key); + } + } + function removeDep(link) { + const { prevDep, nextDep } = link; + if (prevDep) { + prevDep.nextDep = nextDep; + link.prevDep = void 0; + } + if (nextDep) { + nextDep.prevDep = prevDep; + link.nextDep = void 0; + } + } + let shouldTrack = true; + const trackStack = []; + function pauseTracking() { + trackStack.push(shouldTrack); + shouldTrack = false; + } + function resetTracking() { + const last = trackStack.pop(); + shouldTrack = last === void 0 ? true : last; + } + function cleanupEffect(e) { + const { cleanup } = e; + e.cleanup = void 0; + if (cleanup) { + const prevSub = activeSub; + activeSub = void 0; + try { + cleanup(); + } finally { + activeSub = prevSub; + } + } + } + let globalVersion = 0; + class Link { + constructor(sub, dep) { + this.sub = sub; + this.dep = dep; + this.version = dep.version; + this.nextDep = this.prevDep = this.nextSub = this.prevSub = this.prevActiveLink = void 0; + } + } + class Dep { + // TODO isolatedDeclarations "__v_skip" + constructor(computed2) { + this.computed = computed2; + this.version = 0; + this.activeLink = void 0; + this.subs = void 0; + this.map = void 0; + this.key = void 0; + this.sc = 0; + this.__v_skip = true; + } + track(debugInfo) { + if (!activeSub || !shouldTrack || activeSub === this.computed) { + return; + } + let link = this.activeLink; + if (link === void 0 || link.sub !== activeSub) { + link = this.activeLink = new Link(activeSub, this); + if (!activeSub.deps) { + activeSub.deps = activeSub.depsTail = link; + } else { + link.prevDep = activeSub.depsTail; + activeSub.depsTail.nextDep = link; + activeSub.depsTail = link; + } + addSub(link); + } else if (link.version === -1) { + link.version = this.version; + if (link.nextDep) { + const next = link.nextDep; + next.prevDep = link.prevDep; + if (link.prevDep) { + link.prevDep.nextDep = next; + } + link.prevDep = activeSub.depsTail; + link.nextDep = void 0; + activeSub.depsTail.nextDep = link; + activeSub.depsTail = link; + if (activeSub.deps === link) { + activeSub.deps = next; + } + } + } + return link; + } + trigger(debugInfo) { + this.version++; + globalVersion++; + this.notify(debugInfo); + } + notify(debugInfo) { + startBatch(); + try { + if (false) + ; + for (let link = this.subs; link; link = link.prevSub) { + if (link.sub.notify()) { + ; + link.sub.dep.notify(); + } + } + } finally { + endBatch(); + } + } + } + function addSub(link) { + link.dep.sc++; + if (link.sub.flags & 4) { + const computed2 = link.dep.computed; + if (computed2 && !link.dep.subs) { + computed2.flags |= 4 | 16; + for (let l = computed2.deps; l; l = l.nextDep) { + addSub(l); + } + } + const currentTail = link.dep.subs; + if (currentTail !== link) { + link.prevSub = currentTail; + if (currentTail) + currentTail.nextSub = link; + } + link.dep.subs = link; + } + } + const targetMap = /* @__PURE__ */ new WeakMap(); + const ITERATE_KEY = Symbol( + "" + ); + const MAP_KEY_ITERATE_KEY = Symbol( + "" + ); + const ARRAY_ITERATE_KEY = Symbol( + "" + ); + function track(target, type, key) { + if (shouldTrack && activeSub) { + let depsMap = targetMap.get(target); + if (!depsMap) { + targetMap.set(target, depsMap = /* @__PURE__ */ new Map()); + } + let dep = depsMap.get(key); + if (!dep) { + depsMap.set(key, dep = new Dep()); + dep.map = depsMap; + dep.key = key; + } + { + dep.track(); + } + } + } + function trigger(target, type, key, newValue, oldValue, oldTarget) { + const depsMap = targetMap.get(target); + if (!depsMap) { + globalVersion++; + return; + } + const run = (dep) => { + if (dep) { + { + dep.trigger(); + } + } + }; + startBatch(); + if (type === "clear") { + depsMap.forEach(run); + } else { + const targetIsArray = isArray$2(target); + const isArrayIndex = targetIsArray && isIntegerKey(key); + if (targetIsArray && key === "length") { + const newLength = Number(newValue); + depsMap.forEach((dep, key2) => { + if (key2 === "length" || key2 === ARRAY_ITERATE_KEY || !isSymbol(key2) && key2 >= newLength) { + run(dep); + } + }); + } else { + if (key !== void 0 || depsMap.has(void 0)) { + run(depsMap.get(key)); + } + if (isArrayIndex) { + run(depsMap.get(ARRAY_ITERATE_KEY)); + } + switch (type) { + case "add": + if (!targetIsArray) { + run(depsMap.get(ITERATE_KEY)); + if (isMap(target)) { + run(depsMap.get(MAP_KEY_ITERATE_KEY)); + } + } else if (isArrayIndex) { + run(depsMap.get("length")); + } + break; + case "delete": + if (!targetIsArray) { + run(depsMap.get(ITERATE_KEY)); + if (isMap(target)) { + run(depsMap.get(MAP_KEY_ITERATE_KEY)); + } + } + break; + case "set": + if (isMap(target)) { + run(depsMap.get(ITERATE_KEY)); + } + break; + } + } + } + endBatch(); + } + function reactiveReadArray(array) { + const raw = toRaw(array); + if (raw === array) + return raw; + track(raw, "iterate", ARRAY_ITERATE_KEY); + return isShallow(array) ? raw : raw.map(toReactive); + } + function shallowReadArray(arr) { + track(arr = toRaw(arr), "iterate", ARRAY_ITERATE_KEY); + return arr; + } + const arrayInstrumentations = { + __proto__: null, + [Symbol.iterator]() { + return iterator$1(this, Symbol.iterator, toReactive); + }, + concat(...args) { + return reactiveReadArray(this).concat( + ...args.map((x) => isArray$2(x) ? reactiveReadArray(x) : x) + ); + }, + entries() { + return iterator$1(this, "entries", (value) => { + value[1] = toReactive(value[1]); + return value; + }); + }, + every(fn, thisArg) { + return apply(this, "every", fn, thisArg, void 0, arguments); + }, + filter(fn, thisArg) { + return apply(this, "filter", fn, thisArg, (v) => v.map(toReactive), arguments); + }, + find(fn, thisArg) { + return apply(this, "find", fn, thisArg, toReactive, arguments); + }, + findIndex(fn, thisArg) { + return apply(this, "findIndex", fn, thisArg, void 0, arguments); + }, + findLast(fn, thisArg) { + return apply(this, "findLast", fn, thisArg, toReactive, arguments); + }, + findLastIndex(fn, thisArg) { + return apply(this, "findLastIndex", fn, thisArg, void 0, arguments); + }, + // flat, flatMap could benefit from ARRAY_ITERATE but are not straight-forward to implement + forEach(fn, thisArg) { + return apply(this, "forEach", fn, thisArg, void 0, arguments); + }, + includes(...args) { + return searchProxy(this, "includes", args); + }, + indexOf(...args) { + return searchProxy(this, "indexOf", args); + }, + join(separator) { + return reactiveReadArray(this).join(separator); + }, + // keys() iterator only reads `length`, no optimization required + lastIndexOf(...args) { + return searchProxy(this, "lastIndexOf", args); + }, + map(fn, thisArg) { + return apply(this, "map", fn, thisArg, void 0, arguments); + }, + pop() { + return noTracking(this, "pop"); + }, + push(...args) { + return noTracking(this, "push", args); + }, + reduce(fn, ...args) { + return reduce(this, "reduce", fn, args); + }, + reduceRight(fn, ...args) { + return reduce(this, "reduceRight", fn, args); + }, + shift() { + return noTracking(this, "shift"); + }, + // slice could use ARRAY_ITERATE but also seems to beg for range tracking + some(fn, thisArg) { + return apply(this, "some", fn, thisArg, void 0, arguments); + }, + splice(...args) { + return noTracking(this, "splice", args); + }, + toReversed() { + return reactiveReadArray(this).toReversed(); + }, + toSorted(comparer) { + return reactiveReadArray(this).toSorted(comparer); + }, + toSpliced(...args) { + return reactiveReadArray(this).toSpliced(...args); + }, + unshift(...args) { + return noTracking(this, "unshift", args); + }, + values() { + return iterator$1(this, "values", toReactive); + } + }; + function iterator$1(self2, method, wrapValue) { + const arr = shallowReadArray(self2); + const iter = arr[method](); + if (arr !== self2 && !isShallow(self2)) { + iter._next = iter.next; + iter.next = () => { + const result = iter._next(); + if (result.value) { + result.value = wrapValue(result.value); + } + return result; + }; + } + return iter; + } + const arrayProto = Array.prototype; + function apply(self2, method, fn, thisArg, wrappedRetFn, args) { + const arr = shallowReadArray(self2); + const needsWrap = arr !== self2 && !isShallow(self2); + const methodFn = arr[method]; + if (methodFn !== arrayProto[method]) { + const result2 = methodFn.apply(self2, args); + return needsWrap ? toReactive(result2) : result2; + } + let wrappedFn = fn; + if (arr !== self2) { + if (needsWrap) { + wrappedFn = function(item, index) { + return fn.call(this, toReactive(item), index, self2); + }; + } else if (fn.length > 2) { + wrappedFn = function(item, index) { + return fn.call(this, item, index, self2); + }; + } + } + const result = methodFn.call(arr, wrappedFn, thisArg); + return needsWrap && wrappedRetFn ? wrappedRetFn(result) : result; + } + function reduce(self2, method, fn, args) { + const arr = shallowReadArray(self2); + let wrappedFn = fn; + if (arr !== self2) { + if (!isShallow(self2)) { + wrappedFn = function(acc, item, index) { + return fn.call(this, acc, toReactive(item), index, self2); + }; + } else if (fn.length > 3) { + wrappedFn = function(acc, item, index) { + return fn.call(this, acc, item, index, self2); + }; + } + } + return arr[method](wrappedFn, ...args); + } + function searchProxy(self2, method, args) { + const arr = toRaw(self2); + track(arr, "iterate", ARRAY_ITERATE_KEY); + const res = arr[method](...args); + if ((res === -1 || res === false) && isProxy(args[0])) { + args[0] = toRaw(args[0]); + return arr[method](...args); + } + return res; + } + function noTracking(self2, method, args = []) { + pauseTracking(); + startBatch(); + const res = toRaw(self2)[method].apply(self2, args); + endBatch(); + resetTracking(); + return res; + } + const isNonTrackableKeys = /* @__PURE__ */ makeMap(`__proto__,__v_isRef,__isVue`); + const builtInSymbols = new Set( + /* @__PURE__ */ Object.getOwnPropertyNames(Symbol).filter((key) => key !== "arguments" && key !== "caller").map((key) => Symbol[key]).filter(isSymbol) + ); + function hasOwnProperty$1(key) { + if (!isSymbol(key)) + key = String(key); + const obj = toRaw(this); + track(obj, "has", key); + return obj.hasOwnProperty(key); + } + class BaseReactiveHandler { + constructor(_isReadonly = false, _isShallow = false) { + this._isReadonly = _isReadonly; + this._isShallow = _isShallow; + } + get(target, key, receiver) { + if (key === "__v_skip") + return target["__v_skip"]; + const isReadonly2 = this._isReadonly, isShallow2 = this._isShallow; + if (key === "__v_isReactive") { + return !isReadonly2; + } else if (key === "__v_isReadonly") { + return isReadonly2; + } else if (key === "__v_isShallow") { + return isShallow2; + } else if (key === "__v_raw") { + if (receiver === (isReadonly2 ? isShallow2 ? shallowReadonlyMap : readonlyMap : isShallow2 ? shallowReactiveMap : reactiveMap).get(target) || // receiver is not the reactive proxy, but has the same prototype + // this means the receiver is a user proxy of the reactive proxy + Object.getPrototypeOf(target) === Object.getPrototypeOf(receiver)) { + return target; + } + return; + } + const targetIsArray = isArray$2(target); + if (!isReadonly2) { + let fn; + if (targetIsArray && (fn = arrayInstrumentations[key])) { + return fn; + } + if (key === "hasOwnProperty") { + return hasOwnProperty$1; + } + } + const res = Reflect.get( + target, + key, + // if this is a proxy wrapping a ref, return methods using the raw ref + // as receiver so that we don't have to call `toRaw` on the ref in all + // its class methods + isRef(target) ? target : receiver + ); + if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) { + return res; + } + if (!isReadonly2) { + track(target, "get", key); + } + if (isShallow2) { + return res; + } + if (isRef(res)) { + return targetIsArray && isIntegerKey(key) ? res : res.value; + } + if (isObject$1(res)) { + return isReadonly2 ? readonly(res) : reactive(res); + } + return res; + } + } + class MutableReactiveHandler extends BaseReactiveHandler { + constructor(isShallow2 = false) { + super(false, isShallow2); + } + set(target, key, value, receiver) { + let oldValue = target[key]; + if (!this._isShallow) { + const isOldValueReadonly = isReadonly(oldValue); + if (!isShallow(value) && !isReadonly(value)) { + oldValue = toRaw(oldValue); + value = toRaw(value); + } + if (!isArray$2(target) && isRef(oldValue) && !isRef(value)) { + if (isOldValueReadonly) { + return true; + } else { + oldValue.value = value; + return true; + } + } + } + const hadKey = isArray$2(target) && isIntegerKey(key) ? Number(key) < target.length : hasOwn(target, key); + const result = Reflect.set( + target, + key, + value, + isRef(target) ? target : receiver + ); + if (target === toRaw(receiver)) { + if (!hadKey) { + trigger(target, "add", key, value); + } else if (hasChanged(value, oldValue)) { + trigger(target, "set", key, value); + } + } + return result; + } + deleteProperty(target, key) { + const hadKey = hasOwn(target, key); + target[key]; + const result = Reflect.deleteProperty(target, key); + if (result && hadKey) { + trigger(target, "delete", key, void 0); + } + return result; + } + has(target, key) { + const result = Reflect.has(target, key); + if (!isSymbol(key) || !builtInSymbols.has(key)) { + track(target, "has", key); + } + return result; + } + ownKeys(target) { + track( + target, + "iterate", + isArray$2(target) ? "length" : ITERATE_KEY + ); + return Reflect.ownKeys(target); + } + } + class ReadonlyReactiveHandler extends BaseReactiveHandler { + constructor(isShallow2 = false) { + super(true, isShallow2); + } + set(target, key) { + return true; + } + deleteProperty(target, key) { + return true; + } + } + const mutableHandlers = /* @__PURE__ */ new MutableReactiveHandler(); + const readonlyHandlers = /* @__PURE__ */ new ReadonlyReactiveHandler(); + const shallowReactiveHandlers = /* @__PURE__ */ new MutableReactiveHandler(true); + const shallowReadonlyHandlers = /* @__PURE__ */ new ReadonlyReactiveHandler(true); + const toShallow = (value) => value; + const getProto = (v) => Reflect.getPrototypeOf(v); + function createIterableMethod(method, isReadonly2, isShallow2) { + return function(...args) { + const target = this["__v_raw"]; + const rawTarget = toRaw(target); + const targetIsMap = isMap(rawTarget); + const isPair = method === "entries" || method === Symbol.iterator && targetIsMap; + const isKeyOnly = method === "keys" && targetIsMap; + const innerIterator = target[method](...args); + const wrap = isShallow2 ? toShallow : isReadonly2 ? toReadonly : toReactive; + !isReadonly2 && track( + rawTarget, + "iterate", + isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY + ); + return { + // iterator protocol + next() { + const { value, done } = innerIterator.next(); + return done ? { value, done } : { + value: isPair ? [wrap(value[0]), wrap(value[1])] : wrap(value), + done + }; + }, + // iterable protocol + [Symbol.iterator]() { + return this; + } + }; + }; + } + function createReadonlyMethod(type) { + return function(...args) { + return type === "delete" ? false : type === "clear" ? void 0 : this; + }; + } + function createInstrumentations(readonly2, shallow) { + const instrumentations = { + get(key) { + const target = this["__v_raw"]; + const rawTarget = toRaw(target); + const rawKey = toRaw(key); + if (!readonly2) { + if (hasChanged(key, rawKey)) { + track(rawTarget, "get", key); + } + track(rawTarget, "get", rawKey); + } + const { has } = getProto(rawTarget); + const wrap = shallow ? toShallow : readonly2 ? toReadonly : toReactive; + if (has.call(rawTarget, key)) { + return wrap(target.get(key)); + } else if (has.call(rawTarget, rawKey)) { + return wrap(target.get(rawKey)); + } else if (target !== rawTarget) { + target.get(key); + } + }, + get size() { + const target = this["__v_raw"]; + !readonly2 && track(toRaw(target), "iterate", ITERATE_KEY); + return target.size; + }, + has(key) { + const target = this["__v_raw"]; + const rawTarget = toRaw(target); + const rawKey = toRaw(key); + if (!readonly2) { + if (hasChanged(key, rawKey)) { + track(rawTarget, "has", key); + } + track(rawTarget, "has", rawKey); + } + return key === rawKey ? target.has(key) : target.has(key) || target.has(rawKey); + }, + forEach(callback, thisArg) { + const observed = this; + const target = observed["__v_raw"]; + const rawTarget = toRaw(target); + const wrap = shallow ? toShallow : readonly2 ? toReadonly : toReactive; + !readonly2 && track(rawTarget, "iterate", ITERATE_KEY); + return target.forEach((value, key) => { + return callback.call(thisArg, wrap(value), wrap(key), observed); + }); + } + }; + extend$1( + instrumentations, + readonly2 ? { + add: createReadonlyMethod("add"), + set: createReadonlyMethod("set"), + delete: createReadonlyMethod("delete"), + clear: createReadonlyMethod("clear") + } : { + add(value) { + if (!shallow && !isShallow(value) && !isReadonly(value)) { + value = toRaw(value); + } + const target = toRaw(this); + const proto = getProto(target); + const hadKey = proto.has.call(target, value); + if (!hadKey) { + target.add(value); + trigger(target, "add", value, value); + } + return this; + }, + set(key, value) { + if (!shallow && !isShallow(value) && !isReadonly(value)) { + value = toRaw(value); + } + const target = toRaw(this); + const { has, get } = getProto(target); + let hadKey = has.call(target, key); + if (!hadKey) { + key = toRaw(key); + hadKey = has.call(target, key); + } + const oldValue = get.call(target, key); + target.set(key, value); + if (!hadKey) { + trigger(target, "add", key, value); + } else if (hasChanged(value, oldValue)) { + trigger(target, "set", key, value); + } + return this; + }, + delete(key) { + const target = toRaw(this); + const { has, get } = getProto(target); + let hadKey = has.call(target, key); + if (!hadKey) { + key = toRaw(key); + hadKey = has.call(target, key); + } + get ? get.call(target, key) : void 0; + const result = target.delete(key); + if (hadKey) { + trigger(target, "delete", key, void 0); + } + return result; + }, + clear() { + const target = toRaw(this); + const hadItems = target.size !== 0; + const result = target.clear(); + if (hadItems) { + trigger( + target, + "clear", + void 0, + void 0 + ); + } + return result; + } + } + ); + const iteratorMethods = [ + "keys", + "values", + "entries", + Symbol.iterator + ]; + iteratorMethods.forEach((method) => { + instrumentations[method] = createIterableMethod(method, readonly2, shallow); + }); + return instrumentations; + } + function createInstrumentationGetter(isReadonly2, shallow) { + const instrumentations = createInstrumentations(isReadonly2, shallow); + return (target, key, receiver) => { + if (key === "__v_isReactive") { + return !isReadonly2; + } else if (key === "__v_isReadonly") { + return isReadonly2; + } else if (key === "__v_raw") { + return target; + } + return Reflect.get( + hasOwn(instrumentations, key) && key in target ? instrumentations : target, + key, + receiver + ); + }; + } + const mutableCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(false, false) + }; + const shallowCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(false, true) + }; + const readonlyCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(true, false) + }; + const shallowReadonlyCollectionHandlers = { + get: /* @__PURE__ */ createInstrumentationGetter(true, true) + }; + const reactiveMap = /* @__PURE__ */ new WeakMap(); + const shallowReactiveMap = /* @__PURE__ */ new WeakMap(); + const readonlyMap = /* @__PURE__ */ new WeakMap(); + const shallowReadonlyMap = /* @__PURE__ */ new WeakMap(); + function targetTypeMap(rawType) { + switch (rawType) { + case "Object": + case "Array": + return 1; + case "Map": + case "Set": + case "WeakMap": + case "WeakSet": + return 2; + default: + return 0; + } + } + function getTargetType(value) { + return value["__v_skip"] || !Object.isExtensible(value) ? 0 : targetTypeMap(toRawType(value)); + } + function reactive(target) { + if (isReadonly(target)) { + return target; + } + return createReactiveObject( + target, + false, + mutableHandlers, + mutableCollectionHandlers, + reactiveMap + ); + } + function shallowReactive(target) { + return createReactiveObject( + target, + false, + shallowReactiveHandlers, + shallowCollectionHandlers, + shallowReactiveMap + ); + } + function readonly(target) { + return createReactiveObject( + target, + true, + readonlyHandlers, + readonlyCollectionHandlers, + readonlyMap + ); + } + function shallowReadonly(target) { + return createReactiveObject( + target, + true, + shallowReadonlyHandlers, + shallowReadonlyCollectionHandlers, + shallowReadonlyMap + ); + } + function createReactiveObject(target, isReadonly2, baseHandlers, collectionHandlers, proxyMap) { + if (!isObject$1(target)) { + return target; + } + if (target["__v_raw"] && !(isReadonly2 && target["__v_isReactive"])) { + return target; + } + const targetType = getTargetType(target); + if (targetType === 0) { + return target; + } + const existingProxy = proxyMap.get(target); + if (existingProxy) { + return existingProxy; + } + const proxy = new Proxy( + target, + targetType === 2 ? collectionHandlers : baseHandlers + ); + proxyMap.set(target, proxy); + return proxy; + } + function isReactive(value) { + if (isReadonly(value)) { + return isReactive(value["__v_raw"]); + } + return !!(value && value["__v_isReactive"]); + } + function isReadonly(value) { + return !!(value && value["__v_isReadonly"]); + } + function isShallow(value) { + return !!(value && value["__v_isShallow"]); + } + function isProxy(value) { + return value ? !!value["__v_raw"] : false; + } + function toRaw(observed) { + const raw = observed && observed["__v_raw"]; + return raw ? toRaw(raw) : observed; + } + function markRaw(value) { + if (!hasOwn(value, "__v_skip") && Object.isExtensible(value)) { + def(value, "__v_skip", true); + } + return value; + } + const toReactive = (value) => isObject$1(value) ? reactive(value) : value; + const toReadonly = (value) => isObject$1(value) ? readonly(value) : value; + function isRef(r) { + return r ? r["__v_isRef"] === true : false; + } + function ref(value) { + return createRef(value, false); + } + function shallowRef(value) { + return createRef(value, true); + } + function createRef(rawValue, shallow) { + if (isRef(rawValue)) { + return rawValue; + } + return new RefImpl(rawValue, shallow); + } + class RefImpl { + constructor(value, isShallow2) { + this.dep = new Dep(); + this["__v_isRef"] = true; + this["__v_isShallow"] = false; + this._rawValue = isShallow2 ? value : toRaw(value); + this._value = isShallow2 ? value : toReactive(value); + this["__v_isShallow"] = isShallow2; + } + get value() { + { + this.dep.track(); + } + return this._value; + } + set value(newValue) { + const oldValue = this._rawValue; + const useDirectValue = this["__v_isShallow"] || isShallow(newValue) || isReadonly(newValue); + newValue = useDirectValue ? newValue : toRaw(newValue); + if (hasChanged(newValue, oldValue)) { + this._rawValue = newValue; + this._value = useDirectValue ? newValue : toReactive(newValue); + { + this.dep.trigger(); + } + } + } + } + function unref(ref2) { + return isRef(ref2) ? ref2.value : ref2; + } + const shallowUnwrapHandlers = { + get: (target, key, receiver) => key === "__v_raw" ? target : unref(Reflect.get(target, key, receiver)), + set: (target, key, value, receiver) => { + const oldValue = target[key]; + if (isRef(oldValue) && !isRef(value)) { + oldValue.value = value; + return true; + } else { + return Reflect.set(target, key, value, receiver); + } + } + }; + function proxyRefs(objectWithRefs) { + return isReactive(objectWithRefs) ? objectWithRefs : new Proxy(objectWithRefs, shallowUnwrapHandlers); + } + class ComputedRefImpl { + constructor(fn, setter, isSSR) { + this.fn = fn; + this.setter = setter; + this._value = void 0; + this.dep = new Dep(this); + this.__v_isRef = true; + this.deps = void 0; + this.depsTail = void 0; + this.flags = 16; + this.globalVersion = globalVersion - 1; + this.next = void 0; + this.effect = this; + this["__v_isReadonly"] = !setter; + this.isSSR = isSSR; + } + /** + * @internal + */ + notify() { + this.flags |= 16; + if (!(this.flags & 8) && // avoid infinite self recursion + activeSub !== this) { + batch(this, true); + return true; + } + } + get value() { + const link = this.dep.track(); + refreshComputed(this); + if (link) { + link.version = this.dep.version; + } + return this._value; + } + set value(newValue) { + if (this.setter) { + this.setter(newValue); + } + } + } + function computed$1(getterOrOptions, debugOptions, isSSR = false) { + let getter; + let setter; + if (isFunction$2(getterOrOptions)) { + getter = getterOrOptions; + } else { + getter = getterOrOptions.get; + setter = getterOrOptions.set; + } + const cRef = new ComputedRefImpl(getter, setter, isSSR); + return cRef; + } + const INITIAL_WATCHER_VALUE = {}; + const cleanupMap = /* @__PURE__ */ new WeakMap(); + let activeWatcher = void 0; + function onWatcherCleanup(cleanupFn, failSilently = false, owner = activeWatcher) { + if (owner) { + let cleanups = cleanupMap.get(owner); + if (!cleanups) + cleanupMap.set(owner, cleanups = []); + cleanups.push(cleanupFn); + } + } + function watch$1(source, cb, options = EMPTY_OBJ) { + const { immediate, deep, once, scheduler, augmentJob, call } = options; + const reactiveGetter = (source2) => { + if (deep) + return source2; + if (isShallow(source2) || deep === false || deep === 0) + return traverse(source2, 1); + return traverse(source2); + }; + let effect; + let getter; + let cleanup; + let boundCleanup; + let forceTrigger = false; + let isMultiSource = false; + if (isRef(source)) { + getter = () => source.value; + forceTrigger = isShallow(source); + } else if (isReactive(source)) { + getter = () => reactiveGetter(source); + forceTrigger = true; + } else if (isArray$2(source)) { + isMultiSource = true; + forceTrigger = source.some((s) => isReactive(s) || isShallow(s)); + getter = () => source.map((s) => { + if (isRef(s)) { + return s.value; + } else if (isReactive(s)) { + return reactiveGetter(s); + } else if (isFunction$2(s)) { + return call ? call(s, 2) : s(); + } else + ; + }); + } else if (isFunction$2(source)) { + if (cb) { + getter = call ? () => call(source, 2) : source; + } else { + getter = () => { + if (cleanup) { + pauseTracking(); + try { + cleanup(); + } finally { + resetTracking(); + } + } + const currentEffect = activeWatcher; + activeWatcher = effect; + try { + return call ? call(source, 3, [boundCleanup]) : source(boundCleanup); + } finally { + activeWatcher = currentEffect; + } + }; + } + } else { + getter = NOOP; + } + if (cb && deep) { + const baseGetter = getter; + const depth = deep === true ? Infinity : deep; + getter = () => traverse(baseGetter(), depth); + } + const scope = getCurrentScope(); + const watchHandle = () => { + effect.stop(); + if (scope && scope.active) { + remove(scope.effects, effect); + } + }; + if (once && cb) { + const _cb = cb; + cb = (...args) => { + _cb(...args); + watchHandle(); + }; + } + let oldValue = isMultiSource ? new Array(source.length).fill(INITIAL_WATCHER_VALUE) : INITIAL_WATCHER_VALUE; + const job = (immediateFirstRun) => { + if (!(effect.flags & 1) || !effect.dirty && !immediateFirstRun) { + return; + } + if (cb) { + const newValue = effect.run(); + if (deep || forceTrigger || (isMultiSource ? newValue.some((v, i) => hasChanged(v, oldValue[i])) : hasChanged(newValue, oldValue))) { + if (cleanup) { + cleanup(); + } + const currentWatcher = activeWatcher; + activeWatcher = effect; + try { + const args = [ + newValue, + // pass undefined as the old value when it's changed for the first time + oldValue === INITIAL_WATCHER_VALUE ? void 0 : isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE ? [] : oldValue, + boundCleanup + ]; + oldValue = newValue; + call ? call(cb, 3, args) : ( + // @ts-expect-error + cb(...args) + ); + } finally { + activeWatcher = currentWatcher; + } + } + } else { + effect.run(); + } + }; + if (augmentJob) { + augmentJob(job); + } + effect = new ReactiveEffect(getter); + effect.scheduler = scheduler ? () => scheduler(job, false) : job; + boundCleanup = (fn) => onWatcherCleanup(fn, false, effect); + cleanup = effect.onStop = () => { + const cleanups = cleanupMap.get(effect); + if (cleanups) { + if (call) { + call(cleanups, 4); + } else { + for (const cleanup2 of cleanups) + cleanup2(); + } + cleanupMap.delete(effect); + } + }; + if (cb) { + if (immediate) { + job(true); + } else { + oldValue = effect.run(); + } + } else if (scheduler) { + scheduler(job.bind(null, true), true); + } else { + effect.run(); + } + watchHandle.pause = effect.pause.bind(effect); + watchHandle.resume = effect.resume.bind(effect); + watchHandle.stop = watchHandle; + return watchHandle; + } + function traverse(value, depth = Infinity, seen) { + if (depth <= 0 || !isObject$1(value) || value["__v_skip"]) { + return value; + } + seen = seen || /* @__PURE__ */ new Map(); + if ((seen.get(value) || 0) >= depth) { + return value; + } + seen.set(value, depth); + depth--; + if (isRef(value)) { + traverse(value.value, depth, seen); + } else if (isArray$2(value)) { + for (let i = 0; i < value.length; i++) { + traverse(value[i], depth, seen); + } + } else if (isSet(value) || isMap(value)) { + value.forEach((v) => { + traverse(v, depth, seen); + }); + } else if (isPlainObject$1(value)) { + for (const key in value) { + traverse(value[key], depth, seen); + } + for (const key of Object.getOwnPropertySymbols(value)) { + if (Object.prototype.propertyIsEnumerable.call(value, key)) { + traverse(value[key], depth, seen); + } + } + } + return value; + } + /** + * @vue/runtime-core v3.5.21 + * (c) 2018-present Yuxi (Evan) You and Vue contributors + * @license MIT + **/ + const stack = []; + let isWarning = false; + function warn$1(msg, ...args) { + if (isWarning) + return; + isWarning = true; + pauseTracking(); + const instance = stack.length ? stack[stack.length - 1].component : null; + const appWarnHandler = instance && instance.appContext.config.warnHandler; + const trace = getComponentTrace(); + if (appWarnHandler) { + callWithErrorHandling( + appWarnHandler, + instance, + 11, + [ + // eslint-disable-next-line no-restricted-syntax + msg + args.map((a) => { + var _a, _b; + return (_b = (_a = a.toString) == null ? void 0 : _a.call(a)) != null ? _b : JSON.stringify(a); + }).join(""), + instance && instance.proxy, + trace.map( + ({ vnode }) => `at <${formatComponentName(instance, vnode.type)}>` + ).join("\n"), + trace + ] + ); + } else { + const warnArgs = [`[Vue warn]: ${msg}`, ...args]; + if (trace.length && // avoid spamming console during tests + true) { + warnArgs.push(` +`, ...formatTrace(trace)); + } + console.warn(...warnArgs); + } + resetTracking(); + isWarning = false; + } + function getComponentTrace() { + let currentVNode = stack[stack.length - 1]; + if (!currentVNode) { + return []; + } + const normalizedStack = []; + while (currentVNode) { + const last = normalizedStack[0]; + if (last && last.vnode === currentVNode) { + last.recurseCount++; + } else { + normalizedStack.push({ + vnode: currentVNode, + recurseCount: 0 + }); + } + const parentInstance = currentVNode.component && currentVNode.component.parent; + currentVNode = parentInstance && parentInstance.vnode; + } + return normalizedStack; + } + function formatTrace(trace) { + const logs = []; + trace.forEach((entry, i) => { + logs.push(...i === 0 ? [] : [` +`], ...formatTraceEntry(entry)); + }); + return logs; + } + function formatTraceEntry({ vnode, recurseCount }) { + const postfix = recurseCount > 0 ? `... (${recurseCount} recursive calls)` : ``; + const isRoot = vnode.component ? vnode.component.parent == null : false; + const open = ` at <${formatComponentName( + vnode.component, + vnode.type, + isRoot + )}`; + const close = `>` + postfix; + return vnode.props ? [open, ...formatProps(vnode.props), close] : [open + close]; + } + function formatProps(props) { + const res = []; + const keys = Object.keys(props); + keys.slice(0, 3).forEach((key) => { + res.push(...formatProp(key, props[key])); + }); + if (keys.length > 3) { + res.push(` ...`); + } + return res; + } + function formatProp(key, value, raw) { + if (isString$1(value)) { + value = JSON.stringify(value); + return raw ? value : [`${key}=${value}`]; + } else if (typeof value === "number" || typeof value === "boolean" || value == null) { + return raw ? value : [`${key}=${value}`]; + } else if (isRef(value)) { + value = formatProp(key, toRaw(value.value), true); + return raw ? value : [`${key}=Ref<`, value, `>`]; + } else if (isFunction$2(value)) { + return [`${key}=fn${value.name ? `<${value.name}>` : ``}`]; + } else { + value = toRaw(value); + return raw ? value : [`${key}=`, value]; + } + } + function callWithErrorHandling(fn, instance, type, args) { + try { + return args ? fn(...args) : fn(); + } catch (err) { + handleError(err, instance, type); + } + } + function callWithAsyncErrorHandling(fn, instance, type, args) { + if (isFunction$2(fn)) { + const res = callWithErrorHandling(fn, instance, type, args); + if (res && isPromise(res)) { + res.catch((err) => { + handleError(err, instance, type); + }); + } + return res; + } + if (isArray$2(fn)) { + const values = []; + for (let i = 0; i < fn.length; i++) { + values.push(callWithAsyncErrorHandling(fn[i], instance, type, args)); + } + return values; + } + } + function handleError(err, instance, type, throwInDev = true) { + const contextVNode = instance ? instance.vnode : null; + const { errorHandler, throwUnhandledErrorInProduction } = instance && instance.appContext.config || EMPTY_OBJ; + if (instance) { + let cur = instance.parent; + const exposedInstance = instance.proxy; + const errorInfo = `https://vuejs.org/error-reference/#runtime-${type}`; + while (cur) { + const errorCapturedHooks = cur.ec; + if (errorCapturedHooks) { + for (let i = 0; i < errorCapturedHooks.length; i++) { + if (errorCapturedHooks[i](err, exposedInstance, errorInfo) === false) { + return; + } + } + } + cur = cur.parent; + } + if (errorHandler) { + pauseTracking(); + callWithErrorHandling(errorHandler, null, 10, [ + err, + exposedInstance, + errorInfo + ]); + resetTracking(); + return; + } + } + logError(err, type, contextVNode, throwInDev, throwUnhandledErrorInProduction); + } + function logError(err, type, contextVNode, throwInDev = true, throwInProd = false) { + if (throwInProd) { + throw err; + } else { + console.error(err); + } + } + const queue = []; + let flushIndex = -1; + const pendingPostFlushCbs = []; + let activePostFlushCbs = null; + let postFlushIndex = 0; + const resolvedPromise = /* @__PURE__ */ Promise.resolve(); + let currentFlushPromise = null; + function nextTick(fn) { + const p2 = currentFlushPromise || resolvedPromise; + return fn ? p2.then(this ? fn.bind(this) : fn) : p2; + } + function findInsertionIndex$1(id) { + let start = flushIndex + 1; + let end = queue.length; + while (start < end) { + const middle = start + end >>> 1; + const middleJob = queue[middle]; + const middleJobId = getId(middleJob); + if (middleJobId < id || middleJobId === id && middleJob.flags & 2) { + start = middle + 1; + } else { + end = middle; + } + } + return start; + } + function queueJob(job) { + if (!(job.flags & 1)) { + const jobId = getId(job); + const lastJob = queue[queue.length - 1]; + if (!lastJob || // fast path when the job id is larger than the tail + !(job.flags & 2) && jobId >= getId(lastJob)) { + queue.push(job); + } else { + queue.splice(findInsertionIndex$1(jobId), 0, job); + } + job.flags |= 1; + queueFlush(); + } + } + function queueFlush() { + if (!currentFlushPromise) { + currentFlushPromise = resolvedPromise.then(flushJobs); + } + } + function queuePostFlushCb(cb) { + if (!isArray$2(cb)) { + if (activePostFlushCbs && cb.id === -1) { + activePostFlushCbs.splice(postFlushIndex + 1, 0, cb); + } else if (!(cb.flags & 1)) { + pendingPostFlushCbs.push(cb); + cb.flags |= 1; + } + } else { + pendingPostFlushCbs.push(...cb); + } + queueFlush(); + } + function flushPreFlushCbs(instance, seen, i = flushIndex + 1) { + for (; i < queue.length; i++) { + const cb = queue[i]; + if (cb && cb.flags & 2) { + if (instance && cb.id !== instance.uid) { + continue; + } + queue.splice(i, 1); + i--; + if (cb.flags & 4) { + cb.flags &= -2; + } + cb(); + if (!(cb.flags & 4)) { + cb.flags &= -2; + } + } + } + } + function flushPostFlushCbs(seen) { + if (pendingPostFlushCbs.length) { + const deduped = [...new Set(pendingPostFlushCbs)].sort( + (a, b) => getId(a) - getId(b) + ); + pendingPostFlushCbs.length = 0; + if (activePostFlushCbs) { + activePostFlushCbs.push(...deduped); + return; + } + activePostFlushCbs = deduped; + for (postFlushIndex = 0; postFlushIndex < activePostFlushCbs.length; postFlushIndex++) { + const cb = activePostFlushCbs[postFlushIndex]; + if (cb.flags & 4) { + cb.flags &= -2; + } + if (!(cb.flags & 8)) + cb(); + cb.flags &= -2; + } + activePostFlushCbs = null; + postFlushIndex = 0; + } + } + const getId = (job) => job.id == null ? job.flags & 2 ? -1 : Infinity : job.id; + function flushJobs(seen) { + const check = NOOP; + try { + for (flushIndex = 0; flushIndex < queue.length; flushIndex++) { + const job = queue[flushIndex]; + if (job && !(job.flags & 8)) { + if (false) + ; + if (job.flags & 4) { + job.flags &= ~1; + } + callWithErrorHandling( + job, + job.i, + job.i ? 15 : 14 + ); + if (!(job.flags & 4)) { + job.flags &= ~1; + } + } + } + } finally { + for (; flushIndex < queue.length; flushIndex++) { + const job = queue[flushIndex]; + if (job) { + job.flags &= -2; + } + } + flushIndex = -1; + queue.length = 0; + flushPostFlushCbs(); + currentFlushPromise = null; + if (queue.length || pendingPostFlushCbs.length) { + flushJobs(); + } + } + } + let currentRenderingInstance = null; + let currentScopeId = null; + function setCurrentRenderingInstance(instance) { + const prev = currentRenderingInstance; + currentRenderingInstance = instance; + currentScopeId = instance && instance.type.__scopeId || null; + return prev; + } + function withCtx(fn, ctx = currentRenderingInstance, isNonScopedSlot) { + if (!ctx) + return fn; + if (fn._n) { + return fn; + } + const renderFnWithContext = (...args) => { + if (renderFnWithContext._d) { + setBlockTracking(-1); + } + const prevInstance = setCurrentRenderingInstance(ctx); + let res; + try { + res = fn(...args); + } finally { + setCurrentRenderingInstance(prevInstance); + if (renderFnWithContext._d) { + setBlockTracking(1); + } + } + return res; + }; + renderFnWithContext._n = true; + renderFnWithContext._c = true; + renderFnWithContext._d = true; + return renderFnWithContext; + } + function withDirectives(vnode, directives) { + if (currentRenderingInstance === null) { + return vnode; + } + const instance = getComponentPublicInstance(currentRenderingInstance); + const bindings = vnode.dirs || (vnode.dirs = []); + for (let i = 0; i < directives.length; i++) { + let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]; + if (dir) { + if (isFunction$2(dir)) { + dir = { + mounted: dir, + updated: dir + }; + } + if (dir.deep) { + traverse(value); + } + bindings.push({ + dir, + instance, + value, + oldValue: void 0, + arg, + modifiers + }); + } + } + return vnode; + } + function invokeDirectiveHook(vnode, prevVNode, instance, name) { + const bindings = vnode.dirs; + const oldBindings = prevVNode && prevVNode.dirs; + for (let i = 0; i < bindings.length; i++) { + const binding = bindings[i]; + if (oldBindings) { + binding.oldValue = oldBindings[i].value; + } + let hook = binding.dir[name]; + if (hook) { + pauseTracking(); + callWithAsyncErrorHandling(hook, instance, 8, [ + vnode.el, + binding, + vnode, + prevVNode + ]); + resetTracking(); + } + } + } + const TeleportEndKey = Symbol("_vte"); + const isTeleport = (type) => type.__isTeleport; + const leaveCbKey = Symbol("_leaveCb"); + function setTransitionHooks(vnode, hooks) { + if (vnode.shapeFlag & 6 && vnode.component) { + vnode.transition = hooks; + setTransitionHooks(vnode.component.subTree, hooks); + } else if (vnode.shapeFlag & 128) { + vnode.ssContent.transition = hooks.clone(vnode.ssContent); + vnode.ssFallback.transition = hooks.clone(vnode.ssFallback); + } else { + vnode.transition = hooks; + } + } + // @__NO_SIDE_EFFECTS__ + function defineComponent(options, extraOptions) { + return isFunction$2(options) ? ( + // #8236: extend call and options.name access are considered side-effects + // by Rollup, so we have to wrap it in a pure-annotated IIFE. + /* @__PURE__ */ (() => extend$1({ name: options.name }, extraOptions, { setup: options }))() + ) : options; + } + function markAsyncBoundary(instance) { + instance.ids = [instance.ids[0] + instance.ids[2]++ + "-", 0, 0]; + } + const pendingSetRefMap = /* @__PURE__ */ new WeakMap(); + function setRef(rawRef, oldRawRef, parentSuspense, vnode, isUnmount = false) { + if (isArray$2(rawRef)) { + rawRef.forEach( + (r, i) => setRef( + r, + oldRawRef && (isArray$2(oldRawRef) ? oldRawRef[i] : oldRawRef), + parentSuspense, + vnode, + isUnmount + ) + ); + return; + } + if (isAsyncWrapper(vnode) && !isUnmount) { + if (vnode.shapeFlag & 512 && vnode.type.__asyncResolved && vnode.component.subTree.component) { + setRef(rawRef, oldRawRef, parentSuspense, vnode.component.subTree); + } + return; + } + const refValue = vnode.shapeFlag & 4 ? getComponentPublicInstance(vnode.component) : vnode.el; + const value = isUnmount ? null : refValue; + const { i: owner, r: ref2 } = rawRef; + const oldRef = oldRawRef && oldRawRef.r; + const refs = owner.refs === EMPTY_OBJ ? owner.refs = {} : owner.refs; + const setupState = owner.setupState; + const rawSetupState = toRaw(setupState); + const canSetSetupRef = setupState === EMPTY_OBJ ? NO : (key) => { + return hasOwn(rawSetupState, key); + }; + if (oldRef != null && oldRef !== ref2) { + invalidatePendingSetRef(oldRawRef); + if (isString$1(oldRef)) { + refs[oldRef] = null; + if (canSetSetupRef(oldRef)) { + setupState[oldRef] = null; + } + } else if (isRef(oldRef)) { + { + oldRef.value = null; + } + const oldRawRefAtom = oldRawRef; + if (oldRawRefAtom.k) + refs[oldRawRefAtom.k] = null; + } + } + if (isFunction$2(ref2)) { + callWithErrorHandling(ref2, owner, 12, [value, refs]); + } else { + const _isString = isString$1(ref2); + const _isRef = isRef(ref2); + if (_isString || _isRef) { + const doSet = () => { + if (rawRef.f) { + const existing = _isString ? canSetSetupRef(ref2) ? setupState[ref2] : refs[ref2] : ref2.value; + if (isUnmount) { + isArray$2(existing) && remove(existing, refValue); + } else { + if (!isArray$2(existing)) { + if (_isString) { + refs[ref2] = [refValue]; + if (canSetSetupRef(ref2)) { + setupState[ref2] = refs[ref2]; + } + } else { + const newVal = [refValue]; + { + ref2.value = newVal; + } + if (rawRef.k) + refs[rawRef.k] = newVal; + } + } else if (!existing.includes(refValue)) { + existing.push(refValue); + } + } + } else if (_isString) { + refs[ref2] = value; + if (canSetSetupRef(ref2)) { + setupState[ref2] = value; + } + } else if (_isRef) { + { + ref2.value = value; + } + if (rawRef.k) + refs[rawRef.k] = value; + } else + ; + }; + if (value) { + const job = () => { + doSet(); + pendingSetRefMap.delete(rawRef); + }; + job.id = -1; + pendingSetRefMap.set(rawRef, job); + queuePostRenderEffect(job, parentSuspense); + } else { + invalidatePendingSetRef(rawRef); + doSet(); + } + } + } + } + function invalidatePendingSetRef(rawRef) { + const pendingSetRef = pendingSetRefMap.get(rawRef); + if (pendingSetRef) { + pendingSetRef.flags |= 8; + pendingSetRefMap.delete(rawRef); + } + } + getGlobalThis().requestIdleCallback || ((cb) => setTimeout(cb, 1)); + getGlobalThis().cancelIdleCallback || ((id) => clearTimeout(id)); + const isAsyncWrapper = (i) => !!i.type.__asyncLoader; + const isKeepAlive = (vnode) => vnode.type.__isKeepAlive; + function onActivated(hook, target) { + registerKeepAliveHook(hook, "a", target); + } + function onDeactivated(hook, target) { + registerKeepAliveHook(hook, "da", target); + } + function registerKeepAliveHook(hook, type, target = currentInstance) { + const wrappedHook = hook.__wdc || (hook.__wdc = () => { + let current = target; + while (current) { + if (current.isDeactivated) { + return; + } + current = current.parent; + } + return hook(); + }); + injectHook(type, wrappedHook, target); + if (target) { + let current = target.parent; + while (current && current.parent) { + if (isKeepAlive(current.parent.vnode)) { + injectToKeepAliveRoot(wrappedHook, type, target, current); + } + current = current.parent; + } + } + } + function injectToKeepAliveRoot(hook, type, target, keepAliveRoot) { + const injected = injectHook( + type, + hook, + keepAliveRoot, + true + /* prepend */ + ); + onUnmounted(() => { + remove(keepAliveRoot[type], injected); + }, target); + } + function injectHook(type, hook, target = currentInstance, prepend = false) { + if (target) { + const hooks = target[type] || (target[type] = []); + const wrappedHook = hook.__weh || (hook.__weh = (...args) => { + pauseTracking(); + const reset = setCurrentInstance(target); + const res = callWithAsyncErrorHandling(hook, target, type, args); + reset(); + resetTracking(); + return res; + }); + if (prepend) { + hooks.unshift(wrappedHook); + } else { + hooks.push(wrappedHook); + } + return wrappedHook; + } + } + const createHook = (lifecycle) => (hook, target = currentInstance) => { + if (!isInSSRComponentSetup || lifecycle === "sp") { + injectHook(lifecycle, (...args) => hook(...args), target); + } + }; + const onBeforeMount = createHook("bm"); + const onMounted = createHook("m"); + const onBeforeUpdate = createHook( + "bu" + ); + const onUpdated = createHook("u"); + const onBeforeUnmount = createHook( + "bum" + ); + const onUnmounted = createHook("um"); + const onServerPrefetch = createHook( + "sp" + ); + const onRenderTriggered = createHook("rtg"); + const onRenderTracked = createHook("rtc"); + function onErrorCaptured(hook, target = currentInstance) { + injectHook("ec", hook, target); + } + const COMPONENTS = "components"; + function resolveComponent(name, maybeSelfReference) { + return resolveAsset(COMPONENTS, name, true, maybeSelfReference) || name; + } + const NULL_DYNAMIC_COMPONENT = Symbol.for("v-ndc"); + function resolveAsset(type, name, warnMissing = true, maybeSelfReference = false) { + const instance = currentRenderingInstance || currentInstance; + if (instance) { + const Component = instance.type; + if (type === COMPONENTS) { + const selfName = getComponentName( + Component, + false + ); + if (selfName && (selfName === name || selfName === camelize(name) || selfName === capitalize(camelize(name)))) { + return Component; + } + } + const res = ( + // local registration + // check instance[type] first which is resolved for options API + resolve(instance[type] || Component[type], name) || // global registration + resolve(instance.appContext[type], name) + ); + if (!res && maybeSelfReference) { + return Component; + } + return res; + } + } + function resolve(registry, name) { + return registry && (registry[name] || registry[camelize(name)] || registry[capitalize(camelize(name))]); + } + function renderList(source, renderItem, cache, index) { + let ret; + const cached = cache && cache[index]; + const sourceIsArray = isArray$2(source); + if (sourceIsArray || isString$1(source)) { + const sourceIsReactiveArray = sourceIsArray && isReactive(source); + let needsWrap = false; + let isReadonlySource = false; + if (sourceIsReactiveArray) { + needsWrap = !isShallow(source); + isReadonlySource = isReadonly(source); + source = shallowReadArray(source); + } + ret = new Array(source.length); + for (let i = 0, l = source.length; i < l; i++) { + ret[i] = renderItem( + needsWrap ? isReadonlySource ? toReadonly(toReactive(source[i])) : toReactive(source[i]) : source[i], + i, + void 0, + cached && cached[i] + ); + } + } else if (typeof source === "number") { + ret = new Array(source); + for (let i = 0; i < source; i++) { + ret[i] = renderItem(i + 1, i, void 0, cached && cached[i]); + } + } else if (isObject$1(source)) { + if (source[Symbol.iterator]) { + ret = Array.from( + source, + (item, i) => renderItem(item, i, void 0, cached && cached[i]) + ); + } else { + const keys = Object.keys(source); + ret = new Array(keys.length); + for (let i = 0, l = keys.length; i < l; i++) { + const key = keys[i]; + ret[i] = renderItem(source[key], key, i, cached && cached[i]); + } + } + } else { + ret = []; + } + if (cache) { + cache[index] = ret; + } + return ret; + } + const getPublicInstance = (i) => { + if (!i) + return null; + if (isStatefulComponent(i)) + return getComponentPublicInstance(i); + return getPublicInstance(i.parent); + }; + const publicPropertiesMap = ( + // Move PURE marker to new line to workaround compiler discarding it + // due to type annotation + /* @__PURE__ */ extend$1(/* @__PURE__ */ Object.create(null), { + $: (i) => i, + $el: (i) => i.vnode.el, + $data: (i) => i.data, + $props: (i) => i.props, + $attrs: (i) => i.attrs, + $slots: (i) => i.slots, + $refs: (i) => i.refs, + $parent: (i) => getPublicInstance(i.parent), + $root: (i) => getPublicInstance(i.root), + $host: (i) => i.ce, + $emit: (i) => i.emit, + $options: (i) => resolveMergedOptions(i), + $forceUpdate: (i) => i.f || (i.f = () => { + queueJob(i.update); + }), + $nextTick: (i) => i.n || (i.n = nextTick.bind(i.proxy)), + $watch: (i) => instanceWatch.bind(i) + }) + ); + const hasSetupBinding = (state, key) => state !== EMPTY_OBJ && !state.__isScriptSetup && hasOwn(state, key); + const PublicInstanceProxyHandlers = { + get({ _: instance }, key) { + if (key === "__v_skip") { + return true; + } + const { ctx, setupState, data, props, accessCache, type, appContext } = instance; + let normalizedProps; + if (key[0] !== "$") { + const n = accessCache[key]; + if (n !== void 0) { + switch (n) { + case 1: + return setupState[key]; + case 2: + return data[key]; + case 4: + return ctx[key]; + case 3: + return props[key]; + } + } else if (hasSetupBinding(setupState, key)) { + accessCache[key] = 1; + return setupState[key]; + } else if (data !== EMPTY_OBJ && hasOwn(data, key)) { + accessCache[key] = 2; + return data[key]; + } else if ( + // only cache other properties when instance has declared (thus stable) + // props + (normalizedProps = instance.propsOptions[0]) && hasOwn(normalizedProps, key) + ) { + accessCache[key] = 3; + return props[key]; + } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) { + accessCache[key] = 4; + return ctx[key]; + } else if (shouldCacheAccess) { + accessCache[key] = 0; + } + } + const publicGetter = publicPropertiesMap[key]; + let cssModule, globalProperties; + if (publicGetter) { + if (key === "$attrs") { + track(instance.attrs, "get", ""); + } + return publicGetter(instance); + } else if ( + // css module (injected by vue-loader) + (cssModule = type.__cssModules) && (cssModule = cssModule[key]) + ) { + return cssModule; + } else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) { + accessCache[key] = 4; + return ctx[key]; + } else if ( + // global properties + globalProperties = appContext.config.globalProperties, hasOwn(globalProperties, key) + ) { + { + return globalProperties[key]; + } + } else + ; + }, + set({ _: instance }, key, value) { + const { data, setupState, ctx } = instance; + if (hasSetupBinding(setupState, key)) { + setupState[key] = value; + return true; + } else if (data !== EMPTY_OBJ && hasOwn(data, key)) { + data[key] = value; + return true; + } else if (hasOwn(instance.props, key)) { + return false; + } + if (key[0] === "$" && key.slice(1) in instance) { + return false; + } else { + { + ctx[key] = value; + } + } + return true; + }, + has({ + _: { data, setupState, accessCache, ctx, appContext, propsOptions, type } + }, key) { + let normalizedProps, cssModules; + return !!(accessCache[key] || data !== EMPTY_OBJ && key[0] !== "$" && hasOwn(data, key) || hasSetupBinding(setupState, key) || (normalizedProps = propsOptions[0]) && hasOwn(normalizedProps, key) || hasOwn(ctx, key) || hasOwn(publicPropertiesMap, key) || hasOwn(appContext.config.globalProperties, key) || (cssModules = type.__cssModules) && cssModules[key]); + }, + defineProperty(target, key, descriptor) { + if (descriptor.get != null) { + target._.accessCache[key] = 0; + } else if (hasOwn(descriptor, "value")) { + this.set(target, key, descriptor.value, null); + } + return Reflect.defineProperty(target, key, descriptor); + } + }; + function normalizePropsOrEmits(props) { + return isArray$2(props) ? props.reduce( + (normalized, p2) => (normalized[p2] = null, normalized), + {} + ) : props; + } + let shouldCacheAccess = true; + function applyOptions(instance) { + const options = resolveMergedOptions(instance); + const publicThis = instance.proxy; + const ctx = instance.ctx; + shouldCacheAccess = false; + if (options.beforeCreate) { + callHook(options.beforeCreate, instance, "bc"); + } + const { + // state + data: dataOptions, + computed: computedOptions, + methods, + watch: watchOptions, + provide: provideOptions, + inject: injectOptions, + // lifecycle + created, + beforeMount, + mounted, + beforeUpdate, + updated, + activated, + deactivated, + beforeDestroy, + beforeUnmount, + destroyed, + unmounted, + render, + renderTracked, + renderTriggered, + errorCaptured, + serverPrefetch, + // public API + expose, + inheritAttrs, + // assets + components, + directives, + filters + } = options; + const checkDuplicateProperties = null; + if (injectOptions) { + resolveInjections(injectOptions, ctx, checkDuplicateProperties); + } + if (methods) { + for (const key in methods) { + const methodHandler = methods[key]; + if (isFunction$2(methodHandler)) { + { + ctx[key] = methodHandler.bind(publicThis); + } + } + } + } + if (dataOptions) { + const data = dataOptions.call(publicThis, publicThis); + if (!isObject$1(data)) + ; + else { + instance.data = reactive(data); + } + } + shouldCacheAccess = true; + if (computedOptions) { + for (const key in computedOptions) { + const opt = computedOptions[key]; + const get = isFunction$2(opt) ? opt.bind(publicThis, publicThis) : isFunction$2(opt.get) ? opt.get.bind(publicThis, publicThis) : NOOP; + const set = !isFunction$2(opt) && isFunction$2(opt.set) ? opt.set.bind(publicThis) : NOOP; + const c = computed({ + get, + set + }); + Object.defineProperty(ctx, key, { + enumerable: true, + configurable: true, + get: () => c.value, + set: (v) => c.value = v + }); + } + } + if (watchOptions) { + for (const key in watchOptions) { + createWatcher(watchOptions[key], ctx, publicThis, key); + } + } + if (provideOptions) { + const provides = isFunction$2(provideOptions) ? provideOptions.call(publicThis) : provideOptions; + Reflect.ownKeys(provides).forEach((key) => { + provide(key, provides[key]); + }); + } + if (created) { + callHook(created, instance, "c"); + } + function registerLifecycleHook(register, hook) { + if (isArray$2(hook)) { + hook.forEach((_hook) => register(_hook.bind(publicThis))); + } else if (hook) { + register(hook.bind(publicThis)); + } + } + registerLifecycleHook(onBeforeMount, beforeMount); + registerLifecycleHook(onMounted, mounted); + registerLifecycleHook(onBeforeUpdate, beforeUpdate); + registerLifecycleHook(onUpdated, updated); + registerLifecycleHook(onActivated, activated); + registerLifecycleHook(onDeactivated, deactivated); + registerLifecycleHook(onErrorCaptured, errorCaptured); + registerLifecycleHook(onRenderTracked, renderTracked); + registerLifecycleHook(onRenderTriggered, renderTriggered); + registerLifecycleHook(onBeforeUnmount, beforeUnmount); + registerLifecycleHook(onUnmounted, unmounted); + registerLifecycleHook(onServerPrefetch, serverPrefetch); + if (isArray$2(expose)) { + if (expose.length) { + const exposed = instance.exposed || (instance.exposed = {}); + expose.forEach((key) => { + Object.defineProperty(exposed, key, { + get: () => publicThis[key], + set: (val) => publicThis[key] = val, + enumerable: true + }); + }); + } else if (!instance.exposed) { + instance.exposed = {}; + } + } + if (render && instance.render === NOOP) { + instance.render = render; + } + if (inheritAttrs != null) { + instance.inheritAttrs = inheritAttrs; + } + if (components) + instance.components = components; + if (directives) + instance.directives = directives; + if (serverPrefetch) { + markAsyncBoundary(instance); + } + } + function resolveInjections(injectOptions, ctx, checkDuplicateProperties = NOOP) { + if (isArray$2(injectOptions)) { + injectOptions = normalizeInject(injectOptions); + } + for (const key in injectOptions) { + const opt = injectOptions[key]; + let injected; + if (isObject$1(opt)) { + if ("default" in opt) { + injected = inject( + opt.from || key, + opt.default, + true + ); + } else { + injected = inject(opt.from || key); + } + } else { + injected = inject(opt); + } + if (isRef(injected)) { + Object.defineProperty(ctx, key, { + enumerable: true, + configurable: true, + get: () => injected.value, + set: (v) => injected.value = v + }); + } else { + ctx[key] = injected; + } + } + } + function callHook(hook, instance, type) { + callWithAsyncErrorHandling( + isArray$2(hook) ? hook.map((h2) => h2.bind(instance.proxy)) : hook.bind(instance.proxy), + instance, + type + ); + } + function createWatcher(raw, ctx, publicThis, key) { + let getter = key.includes(".") ? createPathGetter(publicThis, key) : () => publicThis[key]; + if (isString$1(raw)) { + const handler = ctx[raw]; + if (isFunction$2(handler)) { + { + watch(getter, handler); + } + } + } else if (isFunction$2(raw)) { + { + watch(getter, raw.bind(publicThis)); + } + } else if (isObject$1(raw)) { + if (isArray$2(raw)) { + raw.forEach((r) => createWatcher(r, ctx, publicThis, key)); + } else { + const handler = isFunction$2(raw.handler) ? raw.handler.bind(publicThis) : ctx[raw.handler]; + if (isFunction$2(handler)) { + watch(getter, handler, raw); + } + } + } else + ; + } + function resolveMergedOptions(instance) { + const base = instance.type; + const { mixins, extends: extendsOptions } = base; + const { + mixins: globalMixins, + optionsCache: cache, + config: { optionMergeStrategies } + } = instance.appContext; + const cached = cache.get(base); + let resolved; + if (cached) { + resolved = cached; + } else if (!globalMixins.length && !mixins && !extendsOptions) { + { + resolved = base; + } + } else { + resolved = {}; + if (globalMixins.length) { + globalMixins.forEach( + (m) => mergeOptions$1(resolved, m, optionMergeStrategies, true) + ); + } + mergeOptions$1(resolved, base, optionMergeStrategies); + } + if (isObject$1(base)) { + cache.set(base, resolved); + } + return resolved; + } + function mergeOptions$1(to, from, strats, asMixin = false) { + const { mixins, extends: extendsOptions } = from; + if (extendsOptions) { + mergeOptions$1(to, extendsOptions, strats, true); + } + if (mixins) { + mixins.forEach( + (m) => mergeOptions$1(to, m, strats, true) + ); + } + for (const key in from) { + if (asMixin && key === "expose") + ; + else { + const strat = internalOptionMergeStrats[key] || strats && strats[key]; + to[key] = strat ? strat(to[key], from[key]) : from[key]; + } + } + return to; + } + const internalOptionMergeStrats = { + data: mergeDataFn, + props: mergeEmitsOrPropsOptions, + emits: mergeEmitsOrPropsOptions, + // objects + methods: mergeObjectOptions, + computed: mergeObjectOptions, + // lifecycle + beforeCreate: mergeAsArray, + created: mergeAsArray, + beforeMount: mergeAsArray, + mounted: mergeAsArray, + beforeUpdate: mergeAsArray, + updated: mergeAsArray, + beforeDestroy: mergeAsArray, + beforeUnmount: mergeAsArray, + destroyed: mergeAsArray, + unmounted: mergeAsArray, + activated: mergeAsArray, + deactivated: mergeAsArray, + errorCaptured: mergeAsArray, + serverPrefetch: mergeAsArray, + // assets + components: mergeObjectOptions, + directives: mergeObjectOptions, + // watch + watch: mergeWatchOptions, + // provide / inject + provide: mergeDataFn, + inject: mergeInject + }; + function mergeDataFn(to, from) { + if (!from) { + return to; + } + if (!to) { + return from; + } + return function mergedDataFn() { + return extend$1( + isFunction$2(to) ? to.call(this, this) : to, + isFunction$2(from) ? from.call(this, this) : from + ); + }; + } + function mergeInject(to, from) { + return mergeObjectOptions(normalizeInject(to), normalizeInject(from)); + } + function normalizeInject(raw) { + if (isArray$2(raw)) { + const res = {}; + for (let i = 0; i < raw.length; i++) { + res[raw[i]] = raw[i]; + } + return res; + } + return raw; + } + function mergeAsArray(to, from) { + return to ? [...new Set([].concat(to, from))] : from; + } + function mergeObjectOptions(to, from) { + return to ? extend$1(/* @__PURE__ */ Object.create(null), to, from) : from; + } + function mergeEmitsOrPropsOptions(to, from) { + if (to) { + if (isArray$2(to) && isArray$2(from)) { + return [.../* @__PURE__ */ new Set([...to, ...from])]; + } + return extend$1( + /* @__PURE__ */ Object.create(null), + normalizePropsOrEmits(to), + normalizePropsOrEmits(from != null ? from : {}) + ); + } else { + return from; + } + } + function mergeWatchOptions(to, from) { + if (!to) + return from; + if (!from) + return to; + const merged = extend$1(/* @__PURE__ */ Object.create(null), to); + for (const key in from) { + merged[key] = mergeAsArray(to[key], from[key]); + } + return merged; + } + function createAppContext() { + return { + app: null, + config: { + isNativeTag: NO, + performance: false, + globalProperties: {}, + optionMergeStrategies: {}, + errorHandler: void 0, + warnHandler: void 0, + compilerOptions: {} + }, + mixins: [], + components: {}, + directives: {}, + provides: /* @__PURE__ */ Object.create(null), + optionsCache: /* @__PURE__ */ new WeakMap(), + propsCache: /* @__PURE__ */ new WeakMap(), + emitsCache: /* @__PURE__ */ new WeakMap() + }; + } + let uid$1 = 0; + function createAppAPI(render, hydrate) { + return function createApp2(rootComponent, rootProps = null) { + if (!isFunction$2(rootComponent)) { + rootComponent = extend$1({}, rootComponent); + } + if (rootProps != null && !isObject$1(rootProps)) { + rootProps = null; + } + const context = createAppContext(); + const installedPlugins = /* @__PURE__ */ new WeakSet(); + const pluginCleanupFns = []; + let isMounted = false; + const app = context.app = { + _uid: uid$1++, + _component: rootComponent, + _props: rootProps, + _container: null, + _context: context, + _instance: null, + version, + get config() { + return context.config; + }, + set config(v) { + }, + use(plugin, ...options) { + if (installedPlugins.has(plugin)) + ; + else if (plugin && isFunction$2(plugin.install)) { + installedPlugins.add(plugin); + plugin.install(app, ...options); + } else if (isFunction$2(plugin)) { + installedPlugins.add(plugin); + plugin(app, ...options); + } else + ; + return app; + }, + mixin(mixin) { + { + if (!context.mixins.includes(mixin)) { + context.mixins.push(mixin); + } + } + return app; + }, + component(name, component) { + if (!component) { + return context.components[name]; + } + context.components[name] = component; + return app; + }, + directive(name, directive) { + if (!directive) { + return context.directives[name]; + } + context.directives[name] = directive; + return app; + }, + mount(rootContainer, isHydrate, namespace) { + if (!isMounted) { + const vnode = app._ceVNode || createVNode(rootComponent, rootProps); + vnode.appContext = context; + if (namespace === true) { + namespace = "svg"; + } else if (namespace === false) { + namespace = void 0; + } + if (isHydrate && hydrate) { + hydrate(vnode, rootContainer); + } else { + render(vnode, rootContainer, namespace); + } + isMounted = true; + app._container = rootContainer; + rootContainer.__vue_app__ = app; + return getComponentPublicInstance(vnode.component); + } + }, + onUnmount(cleanupFn) { + pluginCleanupFns.push(cleanupFn); + }, + unmount() { + if (isMounted) { + callWithAsyncErrorHandling( + pluginCleanupFns, + app._instance, + 16 + ); + render(null, app._container); + delete app._container.__vue_app__; + } + }, + provide(key, value) { + context.provides[key] = value; + return app; + }, + runWithContext(fn) { + const lastApp = currentApp; + currentApp = app; + try { + return fn(); + } finally { + currentApp = lastApp; + } + } + }; + return app; + }; + } + let currentApp = null; + function provide(key, value) { + if (!currentInstance) + ; + else { + let provides = currentInstance.provides; + const parentProvides = currentInstance.parent && currentInstance.parent.provides; + if (parentProvides === provides) { + provides = currentInstance.provides = Object.create(parentProvides); + } + provides[key] = value; + } + } + function inject(key, defaultValue, treatDefaultAsFactory = false) { + const instance = getCurrentInstance(); + if (instance || currentApp) { + let provides = currentApp ? currentApp._context.provides : instance ? instance.parent == null || instance.ce ? instance.vnode.appContext && instance.vnode.appContext.provides : instance.parent.provides : void 0; + if (provides && key in provides) { + return provides[key]; + } else if (arguments.length > 1) { + return treatDefaultAsFactory && isFunction$2(defaultValue) ? defaultValue.call(instance && instance.proxy) : defaultValue; + } else + ; + } + } + const internalObjectProto = {}; + const createInternalObject = () => Object.create(internalObjectProto); + const isInternalObject = (obj) => Object.getPrototypeOf(obj) === internalObjectProto; + function initProps(instance, rawProps, isStateful, isSSR = false) { + const props = {}; + const attrs = createInternalObject(); + instance.propsDefaults = /* @__PURE__ */ Object.create(null); + setFullProps(instance, rawProps, props, attrs); + for (const key in instance.propsOptions[0]) { + if (!(key in props)) { + props[key] = void 0; + } + } + if (isStateful) { + instance.props = isSSR ? props : shallowReactive(props); + } else { + if (!instance.type.props) { + instance.props = attrs; + } else { + instance.props = props; + } + } + instance.attrs = attrs; + } + function updateProps(instance, rawProps, rawPrevProps, optimized) { + const { + props, + attrs, + vnode: { patchFlag } + } = instance; + const rawCurrentProps = toRaw(props); + const [options] = instance.propsOptions; + let hasAttrsChanged = false; + if ( + // always force full diff in dev + // - #1942 if hmr is enabled with sfc component + // - vite#872 non-sfc component used by sfc component + (optimized || patchFlag > 0) && !(patchFlag & 16) + ) { + if (patchFlag & 8) { + const propsToUpdate = instance.vnode.dynamicProps; + for (let i = 0; i < propsToUpdate.length; i++) { + let key = propsToUpdate[i]; + if (isEmitListener(instance.emitsOptions, key)) { + continue; + } + const value = rawProps[key]; + if (options) { + if (hasOwn(attrs, key)) { + if (value !== attrs[key]) { + attrs[key] = value; + hasAttrsChanged = true; + } + } else { + const camelizedKey = camelize(key); + props[camelizedKey] = resolvePropValue( + options, + rawCurrentProps, + camelizedKey, + value, + instance, + false + ); + } + } else { + if (value !== attrs[key]) { + attrs[key] = value; + hasAttrsChanged = true; + } + } + } + } + } else { + if (setFullProps(instance, rawProps, props, attrs)) { + hasAttrsChanged = true; + } + let kebabKey; + for (const key in rawCurrentProps) { + if (!rawProps || // for camelCase + !hasOwn(rawProps, key) && // it's possible the original props was passed in as kebab-case + // and converted to camelCase (#955) + ((kebabKey = hyphenate(key)) === key || !hasOwn(rawProps, kebabKey))) { + if (options) { + if (rawPrevProps && // for camelCase + (rawPrevProps[key] !== void 0 || // for kebab-case + rawPrevProps[kebabKey] !== void 0)) { + props[key] = resolvePropValue( + options, + rawCurrentProps, + key, + void 0, + instance, + true + ); + } + } else { + delete props[key]; + } + } + } + if (attrs !== rawCurrentProps) { + for (const key in attrs) { + if (!rawProps || !hasOwn(rawProps, key) && true) { + delete attrs[key]; + hasAttrsChanged = true; + } + } + } + } + if (hasAttrsChanged) { + trigger(instance.attrs, "set", ""); + } + } + function setFullProps(instance, rawProps, props, attrs) { + const [options, needCastKeys] = instance.propsOptions; + let hasAttrsChanged = false; + let rawCastValues; + if (rawProps) { + for (let key in rawProps) { + if (isReservedProp(key)) { + continue; + } + const value = rawProps[key]; + let camelKey; + if (options && hasOwn(options, camelKey = camelize(key))) { + if (!needCastKeys || !needCastKeys.includes(camelKey)) { + props[camelKey] = value; + } else { + (rawCastValues || (rawCastValues = {}))[camelKey] = value; + } + } else if (!isEmitListener(instance.emitsOptions, key)) { + if (!(key in attrs) || value !== attrs[key]) { + attrs[key] = value; + hasAttrsChanged = true; + } + } + } + } + if (needCastKeys) { + const rawCurrentProps = toRaw(props); + const castValues = rawCastValues || EMPTY_OBJ; + for (let i = 0; i < needCastKeys.length; i++) { + const key = needCastKeys[i]; + props[key] = resolvePropValue( + options, + rawCurrentProps, + key, + castValues[key], + instance, + !hasOwn(castValues, key) + ); + } + } + return hasAttrsChanged; + } + function resolvePropValue(options, props, key, value, instance, isAbsent) { + const opt = options[key]; + if (opt != null) { + const hasDefault = hasOwn(opt, "default"); + if (hasDefault && value === void 0) { + const defaultValue = opt.default; + if (opt.type !== Function && !opt.skipFactory && isFunction$2(defaultValue)) { + const { propsDefaults } = instance; + if (key in propsDefaults) { + value = propsDefaults[key]; + } else { + const reset = setCurrentInstance(instance); + value = propsDefaults[key] = defaultValue.call( + null, + props + ); + reset(); + } + } else { + value = defaultValue; + } + if (instance.ce) { + instance.ce._setProp(key, value); + } + } + if (opt[ + 0 + /* shouldCast */ + ]) { + if (isAbsent && !hasDefault) { + value = false; + } else if (opt[ + 1 + /* shouldCastTrue */ + ] && (value === "" || value === hyphenate(key))) { + value = true; + } + } + } + return value; + } + const mixinPropsCache = /* @__PURE__ */ new WeakMap(); + function normalizePropsOptions(comp, appContext, asMixin = false) { + const cache = asMixin ? mixinPropsCache : appContext.propsCache; + const cached = cache.get(comp); + if (cached) { + return cached; + } + const raw = comp.props; + const normalized = {}; + const needCastKeys = []; + let hasExtends = false; + if (!isFunction$2(comp)) { + const extendProps = (raw2) => { + hasExtends = true; + const [props, keys] = normalizePropsOptions(raw2, appContext, true); + extend$1(normalized, props); + if (keys) + needCastKeys.push(...keys); + }; + if (!asMixin && appContext.mixins.length) { + appContext.mixins.forEach(extendProps); + } + if (comp.extends) { + extendProps(comp.extends); + } + if (comp.mixins) { + comp.mixins.forEach(extendProps); + } + } + if (!raw && !hasExtends) { + if (isObject$1(comp)) { + cache.set(comp, EMPTY_ARR); + } + return EMPTY_ARR; + } + if (isArray$2(raw)) { + for (let i = 0; i < raw.length; i++) { + const normalizedKey = camelize(raw[i]); + if (validatePropName(normalizedKey)) { + normalized[normalizedKey] = EMPTY_OBJ; + } + } + } else if (raw) { + for (const key in raw) { + const normalizedKey = camelize(key); + if (validatePropName(normalizedKey)) { + const opt = raw[key]; + const prop = normalized[normalizedKey] = isArray$2(opt) || isFunction$2(opt) ? { type: opt } : extend$1({}, opt); + const propType = prop.type; + let shouldCast = false; + let shouldCastTrue = true; + if (isArray$2(propType)) { + for (let index = 0; index < propType.length; ++index) { + const type = propType[index]; + const typeName = isFunction$2(type) && type.name; + if (typeName === "Boolean") { + shouldCast = true; + break; + } else if (typeName === "String") { + shouldCastTrue = false; + } + } + } else { + shouldCast = isFunction$2(propType) && propType.name === "Boolean"; + } + prop[ + 0 + /* shouldCast */ + ] = shouldCast; + prop[ + 1 + /* shouldCastTrue */ + ] = shouldCastTrue; + if (shouldCast || hasOwn(prop, "default")) { + needCastKeys.push(normalizedKey); + } + } + } + } + const res = [normalized, needCastKeys]; + if (isObject$1(comp)) { + cache.set(comp, res); + } + return res; + } + function validatePropName(key) { + if (key[0] !== "$" && !isReservedProp(key)) { + return true; + } + return false; + } + const isInternalKey = (key) => key === "_" || key === "_ctx" || key === "$stable"; + const normalizeSlotValue = (value) => isArray$2(value) ? value.map(normalizeVNode) : [normalizeVNode(value)]; + const normalizeSlot$1 = (key, rawSlot, ctx) => { + if (rawSlot._n) { + return rawSlot; + } + const normalized = withCtx((...args) => { + if (false) + ; + return normalizeSlotValue(rawSlot(...args)); + }, ctx); + normalized._c = false; + return normalized; + }; + const normalizeObjectSlots = (rawSlots, slots, instance) => { + const ctx = rawSlots._ctx; + for (const key in rawSlots) { + if (isInternalKey(key)) + continue; + const value = rawSlots[key]; + if (isFunction$2(value)) { + slots[key] = normalizeSlot$1(key, value, ctx); + } else if (value != null) { + const normalized = normalizeSlotValue(value); + slots[key] = () => normalized; + } + } + }; + const normalizeVNodeSlots = (instance, children) => { + const normalized = normalizeSlotValue(children); + instance.slots.default = () => normalized; + }; + const assignSlots = (slots, children, optimized) => { + for (const key in children) { + if (optimized || !isInternalKey(key)) { + slots[key] = children[key]; + } + } + }; + const initSlots = (instance, children, optimized) => { + const slots = instance.slots = createInternalObject(); + if (instance.vnode.shapeFlag & 32) { + const type = children._; + if (type) { + assignSlots(slots, children, optimized); + if (optimized) { + def(slots, "_", type, true); + } + } else { + normalizeObjectSlots(children, slots); + } + } else if (children) { + normalizeVNodeSlots(instance, children); + } + }; + const updateSlots = (instance, children, optimized) => { + const { vnode, slots } = instance; + let needDeletionCheck = true; + let deletionComparisonTarget = EMPTY_OBJ; + if (vnode.shapeFlag & 32) { + const type = children._; + if (type) { + if (optimized && type === 1) { + needDeletionCheck = false; + } else { + assignSlots(slots, children, optimized); + } + } else { + needDeletionCheck = !children.$stable; + normalizeObjectSlots(children, slots); + } + deletionComparisonTarget = children; + } else if (children) { + normalizeVNodeSlots(instance, children); + deletionComparisonTarget = { default: 1 }; + } + if (needDeletionCheck) { + for (const key in slots) { + if (!isInternalKey(key) && deletionComparisonTarget[key] == null) { + delete slots[key]; + } + } + } + }; + const queuePostRenderEffect = queueEffectWithSuspense; + function createRenderer(options) { + return baseCreateRenderer(options); + } + function baseCreateRenderer(options, createHydrationFns) { + const target = getGlobalThis(); + target.__VUE__ = true; + const { + insert: hostInsert, + remove: hostRemove, + patchProp: hostPatchProp, + createElement: hostCreateElement, + createText: hostCreateText, + createComment: hostCreateComment, + setText: hostSetText, + setElementText: hostSetElementText, + parentNode: hostParentNode, + nextSibling: hostNextSibling, + setScopeId: hostSetScopeId = NOOP, + insertStaticContent: hostInsertStaticContent + } = options; + const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, namespace = void 0, slotScopeIds = null, optimized = !!n2.dynamicChildren) => { + if (n1 === n2) { + return; + } + if (n1 && !isSameVNodeType(n1, n2)) { + anchor = getNextHostNode(n1); + unmount(n1, parentComponent, parentSuspense, true); + n1 = null; + } + if (n2.patchFlag === -2) { + optimized = false; + n2.dynamicChildren = null; + } + const { type, ref: ref2, shapeFlag } = n2; + switch (type) { + case Text: + processText(n1, n2, container, anchor); + break; + case Comment: + processCommentNode(n1, n2, container, anchor); + break; + case Static: + if (n1 == null) { + mountStaticNode(n2, container, anchor, namespace); + } + break; + case Fragment: + processFragment( + n1, + n2, + container, + anchor, + parentComponent, + parentSuspense, + namespace, + slotScopeIds, + optimized + ); + break; + default: + if (shapeFlag & 1) { + processElement( + n1, + n2, + container, + anchor, + parentComponent, + parentSuspense, + namespace, + slotScopeIds, + optimized + ); + } else if (shapeFlag & 6) { + processComponent( + n1, + n2, + container, + anchor, + parentComponent, + parentSuspense, + namespace, + slotScopeIds, + optimized + ); + } else if (shapeFlag & 64) { + type.process( + n1, + n2, + container, + anchor, + parentComponent, + parentSuspense, + namespace, + slotScopeIds, + optimized, + internals + ); + } else if (shapeFlag & 128) { + type.process( + n1, + n2, + container, + anchor, + parentComponent, + parentSuspense, + namespace, + slotScopeIds, + optimized, + internals + ); + } else + ; + } + if (ref2 != null && parentComponent) { + setRef(ref2, n1 && n1.ref, parentSuspense, n2 || n1, !n2); + } else if (ref2 == null && n1 && n1.ref != null) { + setRef(n1.ref, null, parentSuspense, n1, true); + } + }; + const processText = (n1, n2, container, anchor) => { + if (n1 == null) { + hostInsert( + n2.el = hostCreateText(n2.children), + container, + anchor + ); + } else { + const el = n2.el = n1.el; + if (n2.children !== n1.children) { + hostSetText(el, n2.children); + } + } + }; + const processCommentNode = (n1, n2, container, anchor) => { + if (n1 == null) { + hostInsert( + n2.el = hostCreateComment(n2.children || ""), + container, + anchor + ); + } else { + n2.el = n1.el; + } + }; + const mountStaticNode = (n2, container, anchor, namespace) => { + [n2.el, n2.anchor] = hostInsertStaticContent( + n2.children, + container, + anchor, + namespace, + n2.el, + n2.anchor + ); + }; + const moveStaticNode = ({ el, anchor }, container, nextSibling) => { + let next; + while (el && el !== anchor) { + next = hostNextSibling(el); + hostInsert(el, container, nextSibling); + el = next; + } + hostInsert(anchor, container, nextSibling); + }; + const removeStaticNode = ({ el, anchor }) => { + let next; + while (el && el !== anchor) { + next = hostNextSibling(el); + hostRemove(el); + el = next; + } + hostRemove(anchor); + }; + const processElement = (n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized) => { + if (n2.type === "svg") { + namespace = "svg"; + } else if (n2.type === "math") { + namespace = "mathml"; + } + if (n1 == null) { + mountElement( + n2, + container, + anchor, + parentComponent, + parentSuspense, + namespace, + slotScopeIds, + optimized + ); + } else { + patchElement( + n1, + n2, + parentComponent, + parentSuspense, + namespace, + slotScopeIds, + optimized + ); + } + }; + const mountElement = (vnode, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized) => { + let el; + let vnodeHook; + const { props, shapeFlag, transition, dirs } = vnode; + el = vnode.el = hostCreateElement( + vnode.type, + namespace, + props && props.is, + props + ); + if (shapeFlag & 8) { + hostSetElementText(el, vnode.children); + } else if (shapeFlag & 16) { + mountChildren( + vnode.children, + el, + null, + parentComponent, + parentSuspense, + resolveChildrenNamespace(vnode, namespace), + slotScopeIds, + optimized + ); + } + if (dirs) { + invokeDirectiveHook(vnode, null, parentComponent, "created"); + } + setScopeId(el, vnode, vnode.scopeId, slotScopeIds, parentComponent); + if (props) { + for (const key in props) { + if (key !== "value" && !isReservedProp(key)) { + hostPatchProp(el, key, null, props[key], namespace, parentComponent); + } + } + if ("value" in props) { + hostPatchProp(el, "value", null, props.value, namespace); + } + if (vnodeHook = props.onVnodeBeforeMount) { + invokeVNodeHook(vnodeHook, parentComponent, vnode); + } + } + if (dirs) { + invokeDirectiveHook(vnode, null, parentComponent, "beforeMount"); + } + const needCallTransitionHooks = needTransition(parentSuspense, transition); + if (needCallTransitionHooks) { + transition.beforeEnter(el); + } + hostInsert(el, container, anchor); + if ((vnodeHook = props && props.onVnodeMounted) || needCallTransitionHooks || dirs) { + queuePostRenderEffect(() => { + vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode); + needCallTransitionHooks && transition.enter(el); + dirs && invokeDirectiveHook(vnode, null, parentComponent, "mounted"); + }, parentSuspense); + } + }; + const setScopeId = (el, vnode, scopeId, slotScopeIds, parentComponent) => { + if (scopeId) { + hostSetScopeId(el, scopeId); + } + if (slotScopeIds) { + for (let i = 0; i < slotScopeIds.length; i++) { + hostSetScopeId(el, slotScopeIds[i]); + } + } + if (parentComponent) { + let subTree = parentComponent.subTree; + if (vnode === subTree || isSuspense(subTree.type) && (subTree.ssContent === vnode || subTree.ssFallback === vnode)) { + const parentVNode = parentComponent.vnode; + setScopeId( + el, + parentVNode, + parentVNode.scopeId, + parentVNode.slotScopeIds, + parentComponent.parent + ); + } + } + }; + const mountChildren = (children, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized, start = 0) => { + for (let i = start; i < children.length; i++) { + const child = children[i] = optimized ? cloneIfMounted(children[i]) : normalizeVNode(children[i]); + patch( + null, + child, + container, + anchor, + parentComponent, + parentSuspense, + namespace, + slotScopeIds, + optimized + ); + } + }; + const patchElement = (n1, n2, parentComponent, parentSuspense, namespace, slotScopeIds, optimized) => { + const el = n2.el = n1.el; + let { patchFlag, dynamicChildren, dirs } = n2; + patchFlag |= n1.patchFlag & 16; + const oldProps = n1.props || EMPTY_OBJ; + const newProps = n2.props || EMPTY_OBJ; + let vnodeHook; + parentComponent && toggleRecurse(parentComponent, false); + if (vnodeHook = newProps.onVnodeBeforeUpdate) { + invokeVNodeHook(vnodeHook, parentComponent, n2, n1); + } + if (dirs) { + invokeDirectiveHook(n2, n1, parentComponent, "beforeUpdate"); + } + parentComponent && toggleRecurse(parentComponent, true); + if (oldProps.innerHTML && newProps.innerHTML == null || oldProps.textContent && newProps.textContent == null) { + hostSetElementText(el, ""); + } + if (dynamicChildren) { + patchBlockChildren( + n1.dynamicChildren, + dynamicChildren, + el, + parentComponent, + parentSuspense, + resolveChildrenNamespace(n2, namespace), + slotScopeIds + ); + } else if (!optimized) { + patchChildren( + n1, + n2, + el, + null, + parentComponent, + parentSuspense, + resolveChildrenNamespace(n2, namespace), + slotScopeIds, + false + ); + } + if (patchFlag > 0) { + if (patchFlag & 16) { + patchProps(el, oldProps, newProps, parentComponent, namespace); + } else { + if (patchFlag & 2) { + if (oldProps.class !== newProps.class) { + hostPatchProp(el, "class", null, newProps.class, namespace); + } + } + if (patchFlag & 4) { + hostPatchProp(el, "style", oldProps.style, newProps.style, namespace); + } + if (patchFlag & 8) { + const propsToUpdate = n2.dynamicProps; + for (let i = 0; i < propsToUpdate.length; i++) { + const key = propsToUpdate[i]; + const prev = oldProps[key]; + const next = newProps[key]; + if (next !== prev || key === "value") { + hostPatchProp(el, key, prev, next, namespace, parentComponent); + } + } + } + } + if (patchFlag & 1) { + if (n1.children !== n2.children) { + hostSetElementText(el, n2.children); + } + } + } else if (!optimized && dynamicChildren == null) { + patchProps(el, oldProps, newProps, parentComponent, namespace); + } + if ((vnodeHook = newProps.onVnodeUpdated) || dirs) { + queuePostRenderEffect(() => { + vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, n2, n1); + dirs && invokeDirectiveHook(n2, n1, parentComponent, "updated"); + }, parentSuspense); + } + }; + const patchBlockChildren = (oldChildren, newChildren, fallbackContainer, parentComponent, parentSuspense, namespace, slotScopeIds) => { + for (let i = 0; i < newChildren.length; i++) { + const oldVNode = oldChildren[i]; + const newVNode = newChildren[i]; + const container = ( + // oldVNode may be an errored async setup() component inside Suspense + // which will not have a mounted element + oldVNode.el && // - In the case of a Fragment, we need to provide the actual parent + // of the Fragment itself so it can move its children. + (oldVNode.type === Fragment || // - In the case of different nodes, there is going to be a replacement + // which also requires the correct parent container + !isSameVNodeType(oldVNode, newVNode) || // - In the case of a component, it could contain anything. + oldVNode.shapeFlag & (6 | 64 | 128)) ? hostParentNode(oldVNode.el) : ( + // In other cases, the parent container is not actually used so we + // just pass the block element here to avoid a DOM parentNode call. + fallbackContainer + ) + ); + patch( + oldVNode, + newVNode, + container, + null, + parentComponent, + parentSuspense, + namespace, + slotScopeIds, + true + ); + } + }; + const patchProps = (el, oldProps, newProps, parentComponent, namespace) => { + if (oldProps !== newProps) { + if (oldProps !== EMPTY_OBJ) { + for (const key in oldProps) { + if (!isReservedProp(key) && !(key in newProps)) { + hostPatchProp( + el, + key, + oldProps[key], + null, + namespace, + parentComponent + ); + } + } + } + for (const key in newProps) { + if (isReservedProp(key)) + continue; + const next = newProps[key]; + const prev = oldProps[key]; + if (next !== prev && key !== "value") { + hostPatchProp(el, key, prev, next, namespace, parentComponent); + } + } + if ("value" in newProps) { + hostPatchProp(el, "value", oldProps.value, newProps.value, namespace); + } + } + }; + const processFragment = (n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized) => { + const fragmentStartAnchor = n2.el = n1 ? n1.el : hostCreateText(""); + const fragmentEndAnchor = n2.anchor = n1 ? n1.anchor : hostCreateText(""); + let { patchFlag, dynamicChildren, slotScopeIds: fragmentSlotScopeIds } = n2; + if (fragmentSlotScopeIds) { + slotScopeIds = slotScopeIds ? slotScopeIds.concat(fragmentSlotScopeIds) : fragmentSlotScopeIds; + } + if (n1 == null) { + hostInsert(fragmentStartAnchor, container, anchor); + hostInsert(fragmentEndAnchor, container, anchor); + mountChildren( + // #10007 + // such fragment like `<>` will be compiled into + // a fragment which doesn't have a children. + // In this case fallback to an empty array + n2.children || [], + container, + fragmentEndAnchor, + parentComponent, + parentSuspense, + namespace, + slotScopeIds, + optimized + ); + } else { + if (patchFlag > 0 && patchFlag & 64 && dynamicChildren && // #2715 the previous fragment could've been a BAILed one as a result + // of renderSlot() with no valid children + n1.dynamicChildren) { + patchBlockChildren( + n1.dynamicChildren, + dynamicChildren, + container, + parentComponent, + parentSuspense, + namespace, + slotScopeIds + ); + if ( + // #2080 if the stable fragment has a key, it's a