diff --git a/AGENTS.md b/AGENTS.md index ca9ae4ffb..b5d086f3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Start local docs server: `npm start` - Build documentation: `npm run build` - Serve built documentation: `npm run serve` -- Process CRDs: `npm run process-crds` - Clear Docusaurus cache: `npm run clear` - Deploy documentation: `npm run deploy` - Generate translations: `npm run write-translations` @@ -38,5 +37,4 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - This is a Docusaurus site, not Hugo - Requires Node.js 18.x and npm 9.x - Use `npm start` for local development with hot reloading -- CRDs are processed via script before start/build commands - Site uses React components and MDX for enhanced functionality \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ddb9f71dc..6c1821e55 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,6 +57,24 @@ make build All PRs require maintainer approval to merge, regardless of Vale status. +## Updating CRDs + +CRD reference pages (`docs/reference/apis/*`) render live from raw CRD YAML +files committed under `static/crds//`. There's no generation step — +Docusaurus serves `static/` files as-is, and the `CrdDocViewer` component +fetches the YAML client-side by URL: + +```mdx + +``` + +To add or update a CRD: + +1. Copy the CRD's YAML file from its source repo (UXP, Crossplane, Spaces, + etc.) into the matching `static/crds//` directory. +2. Reference it from the relevant page with ``. +3. Commit the YAML file alongside your doc changes in the same PR. + ## VSCode Setup (Recommended) For the best documentation editing experience, install these extensions: diff --git a/Makefile b/Makefile index cfd17fe17..4f21df103 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ VALE_BINARY := vale_$(VALE_VERSION)_$(VALE_OS)_$(VALE_ARCH).tar.gz VALE_INSTALL_DIR := ./bin VALE_EXEC := $(VALE_INSTALL_DIR)/vale -.PHONY: help install-vale vale vale-docs vale-file vale-summary clean build start serve process-crds +.PHONY: help install-vale vale vale-docs vale-file vale-summary clean build start serve # Default target help: ## Show this help message @@ -46,7 +46,7 @@ help: ## Show this help message install: ## Install dependencies npm install -build: process-crds ## Build the documentation site +build: ## Build the documentation site npm run build start: ## Start local development server @@ -55,9 +55,6 @@ start: ## Start local development server serve: ## Serve built documentation npm run serve -process-crds: ## Process CRDs for documentation - npm run process-crds - # Vale installation and linting commands install-vale: ## Install Vale binary locally @echo "Installing Vale $(VALE_VERSION) for $(VALE_OS) $(VALE_ARCH)..." @@ -130,7 +127,7 @@ dev: install start ## Install dependencies and start development server # CI/CD targets ci-lint: install-vale vale-summary ## Run linting for CI/CD pipeline -ci-build: install process-crds build ## Build for CI/CD pipeline +ci-build: install build ## Build for CI/CD pipeline # Version info version: ## Show versions of tools diff --git a/README.md b/README.md index 5ea682a96..fdc587bbe 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,9 @@ make check-vale-config | Command | Description | |---------|-------------| | `make install` | Install npm dependencies | -| `make build` | Build the documentation site (includes CRD processing) | +| `make build` | Build the documentation site | | `make start` | Start local development server with hot reload | | `make serve` | Serve the built documentation | -| `make process-crds` | Process Custom Resource Definitions for documentation | | `make dev` | Install dependencies and start development server | | `make ci-build` | Full build process for CI/CD | diff --git a/docs/getstarted/upgrade-to-upbound/upgrade-to-mcps.md b/cloud-spaces-docs/upgrade-to-mcps.md similarity index 99% rename from docs/getstarted/upgrade-to-upbound/upgrade-to-mcps.md rename to cloud-spaces-docs/upgrade-to-mcps.md index 2fce16834..15e200be1 100644 --- a/docs/getstarted/upgrade-to-upbound/upgrade-to-mcps.md +++ b/cloud-spaces-docs/upgrade-to-mcps.md @@ -1,6 +1,6 @@ --- title: Upgrade to Spaces -sidebar_position: 3 +sidebar_position: 4 description: A guide to how to update to a control plane in an Upbound Space --- diff --git a/docs/getstarted/builders-workshop/1-project-foundations.md b/docs/getstarted/builders-workshop/1-project-foundations.md index a08423bca..1717bf7b8 100644 --- a/docs/getstarted/builders-workshop/1-project-foundations.md +++ b/docs/getstarted/builders-workshop/1-project-foundations.md @@ -131,7 +131,7 @@ you want to apply to this `StorageBucket` resource. Paste this XR into your XR file: - + ```yaml title="upbound-hello-world/examples/storagebucket/example.yaml" diff --git a/docs/getstarted/builders-workshop/2-create-configuration.md b/docs/getstarted/builders-workshop/2-create-configuration.md index c104117af..b841a2d5d 100644 --- a/docs/getstarted/builders-workshop/2-create-configuration.md +++ b/docs/getstarted/builders-workshop/2-create-configuration.md @@ -4,6 +4,8 @@ description: Create a composition function --- import GlobalLanguageSelector, { CodeBlock } from '@site/src/components/GlobalLanguageSelector'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; @@ -46,7 +48,7 @@ outputs. Generate the composition function scaffolding and choose your preferred language: - + ```shell up function generate example-function apis/storagebuckets/composition.yaml --language=kcl @@ -54,7 +56,7 @@ up function generate example-function apis/storagebuckets/composition.yaml --lan - + ```shell up function generate example-function apis/storagebuckets/composition.yaml --language=python @@ -62,41 +64,22 @@ up function generate example-function apis/storagebuckets/composition.yaml --lan - - - -```shell -up function generate example-function apis/storagebuckets/composition.yaml --language=kcl -``` - - - - + ```shell -up function generate example-function apis/storagebuckets/composition.yaml --language=python +up function generate example-function apis/storagebuckets/composition.yaml --language=go ``` - - + ```shell -up function generate example-function apis/storagebuckets/composition.yaml --language=kcl +up function generate example-function apis/storagebuckets/composition.yaml --language=go-templating ``` - - -```shell -up function generate example-function apis/storagebuckets/composition.yaml --language=python -``` - - - - This command creates a function directory and creates a new file based on your chosen language. @@ -109,123 +92,7 @@ Next, create the actual program logic that builds your cloud resources. Paste the following into `main.k`: -```yaml title="upbound-hello-world/functions/example-function/main.k" -import models.io.upbound.awsm.s3.v1beta1 as awsms3v1beta1 - -oxr = option("params").oxr # observed composite resource -params = oxr.spec.parameters # extract parameter values from XR - -_metadata = lambda name: str -> any { - { - generateName = name # due to global S3 naming restrictions we'll have - # Crossplane generate a name to garauntee uniqueness - annotations = { - "krm.kcl.dev/composition-resource-name" = name - } - } -} - -_items: [any] = [ - # Create S3 Bucket - awsms3v1beta1.Bucket { - metadata: _metadata("{}-bucket".format(oxr.metadata.name)) - spec = { - forProvider = { - region = params.region - } - } - }, - - # Bucket BOC - awsms3v1beta1.BucketOwnershipControls { - metadata: _metadata("{}-boc".format(oxr.metadata.name)) - spec = { - forProvider = { - bucketSelector: { - matchControllerRef: True - } - region = params.region - rule = { - objectOwnership = "BucketOwnerPreferred" - } - } - } - }, - - # Bucket PAB - awsms3v1beta1.BucketPublicAccessBlock { - metadata: _metadata("{}-pab".format(oxr.metadata.name)) - spec = { - forProvider = { - bucketSelector: { - matchControllerRef: True - } - region = params.region - blockPublicAcls: False - ignorePublicAcls: False - restrictPublicBuckets: False - blockPublicPolicy: False - } - } - }, - - # Bucket ACL - awsms3v1beta1.BucketACL { - metadata: _metadata("{}-acl".format(oxr.metadata.name)) - spec = { - forProvider = { - bucketSelector: { - matchControllerRef: True - } - region = params.region - acl = params.acl - } - } - }, - - # Default encryption for the bucket - awsms3v1beta1.BucketServerSideEncryptionConfiguration { - metadata: _metadata("{}-encryption".format(oxr.metadata.name)) - spec = { - forProvider = { - region = params.region - bucketSelector: { - matchControllerRef: True - } - rule = [ - { - applyServerSideEncryptionByDefault = { - sseAlgorithm = "AES256" - } - bucketKeyEnabled = True - } - ] - } - } - } -] - -# Set up versioning for the bucket if desired -if params.versioning: - _items += [ - awsms3v1beta1.BucketVersioning{ - metadata: _metadata("{}-versioning".format(oxr.metadata.name)) - spec = { - forProvider = { - region = params.region - bucketSelector: { - matchControllerRef: True - } - versioningConfiguration = { - status = "Enabled" - } - } - } - } - ] - -items = _items - +```yaml title="upbound-hello-world/functions/example-function/main.k" manifest="/manifests/getstarted/create-configuration/aws-main.k" ``` @@ -234,121 +101,7 @@ items = _items Paste the following into `main.py`: -```python title="upbound-hello-world/functions/example-function/main.py" -from crossplane.function import resource -from crossplane.function.proto.v1 import run_function_pb2 as fnv1 - -from .model.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1 -from .model.com.example.platform.storagebucket import v1alpha1 -from .model.io.upbound.m.aws.s3.bucket import v1beta1 as mbucketv1beta1 -from .model.io.upbound.m.aws.s3.bucketacl import v1beta1 as maclv1beta1 -from .model.io.upbound.m.aws.s3.bucketownershipcontrols import v1beta1 as mbocv1beta1 -from .model.io.upbound.m.aws.s3.bucketpublicaccessblock import v1beta1 as mpabv1beta1 -from .model.io.upbound.m.aws.s3.bucketversioning import v1beta1 as mverv1beta1 -from .model.io.upbound.m.aws.s3.bucketserversideencryptionconfiguration import v1beta1 as mssev1beta1 - -def resource_name(xr, resource): - return "{}-{}".format(xr.metadata.name, resource) - -def default_metadata(name): - return { - "generateName": name - } - -def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): - observed_xr = v1alpha1.StorageBucket(**req.observed.composite.resource) - params = observed_xr.spec.parameters - - # Create S3 Bucket - desired_bucket = mbucketv1beta1.Bucket( - metadata = default_metadata(resource_name(observed_xr, "bucket")), - spec = mbucketv1beta1.Spec( - forProvider = mbucketv1beta1.ForProvider( - region = params.region, - ), - ), - ) - resource.update(rsp.desired.resources["bucket"], desired_bucket) - - # Bucket BOC - desired_boc = mbocv1beta1.BucketOwnershipControls( - metadata = default_metadata(resource_name(observed_xr, "boc")), - spec = mbocv1beta1.Spec( - forProvider = mbocv1beta1.ForProvider( - region = params.region, - bucketSelector = mbocv1beta1.BucketSelector(matchControllerRef = True), - rule = { - "objectOwnership": "BucketOwnerPreferred" - } - ) - ), - ) - resource.update(rsp.desired.resources["boc"], desired_boc) - - # Bucket PAB - desired_pab = mpabv1beta1.BucketPublicAccessBlock( - metadata = default_metadata(resource_name(observed_xr, "pab")), - spec=mpabv1beta1.Spec( - forProvider = mpabv1beta1.ForProvider( - region = params.region, - bucketSelector = mpabv1beta1.BucketSelector(matchControllerRef = True), - blockPublicAcls = False, - ignorePublicAcls = False, - restrictPublicBuckets = False, - blockPublicPolicy = False, - ) - ), - ) - resource.update(rsp.desired.resources["pab"], desired_pab) - - # Bucket ACL - desired_acl = maclv1beta1.BucketACL( - metadata = default_metadata(resource_name(observed_xr, "acl")), - spec = maclv1beta1.Spec( - forProvider = maclv1beta1.ForProvider( - region = params.region, - bucketSelector = maclv1beta1.BucketSelector(matchControllerRef = True), - acl = params.acl, - ), - ), - ) - resource.update(rsp.desired.resources["acl"], desired_acl) - - # Default encryption for the bucket - desired_sse = mssev1beta1.BucketServerSideEncryptionConfiguration( - metadata = default_metadata(resource_name(observed_xr, "encryption")), - spec = mssev1beta1.Spec( - forProvider = mssev1beta1.ForProvider( - region = params.region, - bucketSelector = mssev1beta1.BucketSelector(matchControllerRef = True), - rule = [ - mssev1beta1.RuleItem( - applyServerSideEncryptionByDefault = { - "sseAlgorithm": "AES256" - }, - bucketKeyEnabled = True - ) - ] - ), - ), - ) - resource.update(rsp.desired.resources["sse"], desired_sse) - - # Set up versioning for the bucket if desired - if params.versioning: - desired_versioning = mverv1beta1.BucketVersioning( - metadata = default_metadata(resource_name(observed_xr, "versioning")), - spec = mverv1beta1.Spec( - forProvider = mverv1beta1.ForProvider( - region = params.region, - bucketSelector = mverv1beta1.BucketSelector(matchControllerRef = True), - versioningConfiguration = { - "status": "Enabled" - } - ), - ), - ) - resource.update(rsp.desired.resources["versioning"], desired_versioning) +```python title="upbound-hello-world/functions/example-function/main.py" manifest="/manifests/getstarted/create-configuration/aws-main.py" ``` @@ -358,84 +111,7 @@ def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): Paste the following into `main.k`: -```yaml title="upbound-hello-world/functions/example-function/main.k" - -import regex -import models.io.upbound.azurem.v1beta1 as azuremv1beta1 -import models.io.upbound.azurem.storage.v1beta1 as azuremstoragev1beta1 - -oxr = option("params").oxr # observed composite resource -params = oxr.spec.parameters # extract parameter values from XR -xr_metadata = oxr.metadata # store XR metadata - -_metadata = lambda name: str -> any { - { - name = name - annotations = { - "krm.kcl.dev/composition-resource-name" = name - } - } -} - -sanitize_azure_storage_account_name = lambda name: str -> str { - # Due to Azure's naming restrictions, storage account names must - # be between 3-24 characters in length and use numbers and lower-case letters only - - # lower string and remove illegal characters - sanitized = name.lower() - sanitized = regex.replace(sanitized, "[^a-z0-9]", "") - - # pad with 0s if string name less than 3 characters - if len(sanitized) < 3: - sanitized = "{:0<3}".format(sanitized) - - # trim string to 24 characters - sanitized = sanitized[:24] -} - -_items = [ - azuremv1beta1.ResourceGroup { - metadata = _metadata("{}-group".format(xr_metadata.name)) - spec = { - forProvider = { - location = params.location - } - } - }, - azuremstoragev1beta1.Account { - metadata = _metadata(sanitize_azure_storage_account_name("{}account".format(xr_metadata.name))) - spec = { - forProvider = { - accountTier = "Standard" - accountReplicationType = "LRS" - location = params.location - blobProperties = { - versioningEnabled = params.versioning - } - infrastructureEncryptionEnabled = True - resourceGroupNameSelector = { - matchControllerRef = True - } - } - } - }, - azuremstoragev1beta1.Container { - metadata: _metadata("{}-container".format(xr_metadata.name)) - spec = { - forProvider = { - if params.acl == "public": - containerAccessType = "blob" - else: - containerAccessType = "private" - storageAccountNameSelector = { - matchControllerRef = True - } - } - } - } -] -items = _items - +```yaml title="upbound-hello-world/functions/example-function/main.k" manifest="/manifests/getstarted/create-configuration/azure-main.k" ``` @@ -444,200 +120,80 @@ items = _items Paste the following into `main.py`: +```python title="upbound-hello-world/functions/example-function/main.py" manifest="/manifests/getstarted/create-configuration/azure-main.py" +``` -```python title="upbound-hello-world/functions/example-function/main.py" -import re + -from crossplane.function import resource -from crossplane.function.proto.v1 import run_function_pb2 as fnv1 -from .model.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1 -from .model.io.upbound.m.azure.resourcegroup import v1beta1 as rgv1beta1 -from .model.io.upbound.m.azure.storage.account import v1beta1 as acctv1beta1 -from .model.io.upbound.m.azure.storage.container import v1beta1 as contv1beta1 -from .model.com.example.platform.storagebucket import v1alpha1 + -def resource_name(xr, resource): - return "{}-{}".format(xr.metadata.name, resource) +Paste the following into `main.k`: -def default_metadata(name): - return { - "name": name - } +```yaml title="upbound-hello-world/functions/example-function/main.k" manifest="/manifests/getstarted/create-configuration/gcp-main.k" +``` -def sanitize_azure_storage_account_name(account_name): - # Due to Azure's naming restrictions, storage account names must - # be between 3-24 characters in length and use numbers and lower-case letters only + - # Convert to lowercase and remove all non-alphanumeric characters - sanitized = re.sub(r'[^a-z0-9]', '', account_name.lower()) - - # Ensure minimum length of 3 - if len(sanitized) < 3: - sanitized = sanitized.ljust(3, '0') - - # Ensure maximum length of 24 - if len(sanitized) > 24: - sanitized = sanitized[:24] - - return sanitized + -def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): - observed_xr = v1alpha1.StorageBucket(**resource.struct_to_dict(req.observed.composite.resource)) - params = observed_xr.spec.parameters +Paste the following into `main.py`: - desired_group = rgv1beta1.ResourceGroup( - metadata = default_metadata(resource_name(observed_xr, "group")), - spec = rgv1beta1.Spec( - forProvider = rgv1beta1.ForProvider( - location = params.location, - ), - ), - ) - resource.update(rsp.desired.resources["group"], desired_group) - - desired_acct = acctv1beta1.Account( - metadata = default_metadata(sanitize_azure_storage_account_name(resource_name(observed_xr, "account"))), - spec = acctv1beta1.Spec( - forProvider = acctv1beta1.ForProvider( - accountTier = "Standard", - accountReplicationType = "LRS", - location = params.location, - infrastructureEncryptionEnabled = True, - blobProperties = { - "versioningEnabled": params.versioning - }, - resourceGroupNameSelector = acctv1beta1.ResourceGroupNameSelector(matchControllerRef = True) - ), - ), - ) - resource.update(rsp.desired.resources["account"], desired_acct) - - desired_cont = contv1beta1.Container( - metadata = default_metadata(resource_name(observed_xr, "container")), - spec = contv1beta1.Spec( - forProvider = contv1beta1.ForProvider( - containerAccessType = "blob" if params.acl == "public" else "private", - storageAccountNameSelector = contv1beta1.StorageAccountNameSelector(matchControllerRef = True) - ), - ), - ) - resource.update(rsp.desired.resources["container"], desired_cont) +```python title="upbound-hello-world/functions/example-function/main.py" manifest="/manifests/getstarted/create-configuration/gcp-main.py" ``` + - +This example targets AWS. Paste the following into `fn.go`: -Paste the following into `main.k`: +```go title="upbound-hello-world/functions/example-function/fn.go" manifest="/manifests/getstarted/create-configuration/aws-fn.go" +``` -```yaml title="upbound-hello-world/functions/example-function/main.k" +For the full Go composition function guide, including working with generated +models, see [Create a composition with Go][go-compositions]. -import models.io.upbound.gcpm.storage.v1beta1 as gcpmstoragev1beta1 + -oxr = option("params").oxr # observed composite resource -params = oxr.spec.parameters # extract parameter values from XR + -_metadata = lambda name: str -> any { - { - name = name - annotations = { - "krm.kcl.dev/composition-resource-name" = name - } - } -} +This example targets AWS and covers the bucket, ACL, and versioning resources. +Paste each file below into your function's directory, matching the filenames: -_items: [any] = [ - # Create GCP Bucket - gcpmstoragev1beta1.Bucket { - metadata: _metadata("{}-bucket".format(oxr.metadata.name)) - spec = { - forProvider = { - location = params.location - versioning = { - enabled = params.versioning - } - } - } - }, - - # Bucket ACL - gcpmstoragev1beta1.BucketACL { - metadata: _metadata("{}-acl".format(oxr.metadata.name)) - spec = { - forProvider = { - predefinedAcl = params.acl - bucketSelector = { - matchControllerRef = True - } - } - } - } -] - -items = _items + + +```yaml manifest="/manifests/getstarted/create-configuration/aws-00-prelude.yaml.gotmpl" ``` - - - - -Paste the following into `main.py`: + + -```python title="upbound-hello-world/functions/example-function/main.py" +```yaml manifest="/manifests/getstarted/create-configuration/aws-01-bucket.yaml.gotmpl" +``` -from crossplane.function import resource -from crossplane.function.proto.v1 import run_function_pb2 as fnv1 + + -from .model.io.upbound.m.gcp.storage.bucket import v1beta1 as mbucketv1beta1 -from .model.io.upbound.m.gcp.storage.bucketacl import v1beta1 as maclv1beta1 -from .model.com.example.platform.storagebucket import v1alpha1 +```yaml manifest="/manifests/getstarted/create-configuration/aws-02-acl.yaml.gotmpl" +``` -def resource_name(xr, resource): - return "{}-{}".format(xr.metadata.name, resource) + + -def default_metadata(name): - return { - "name": name - } +```yaml manifest="/manifests/getstarted/create-configuration/aws-03-versioning.yaml.gotmpl" +``` -def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): - observed_xr = v1alpha1.StorageBucket(**req.observed.composite.resource) - params = observed_xr.spec.parameters - - # Create GCP Bucket - desired_bucket = mbucketv1beta1.Bucket( - metadata = default_metadata(resource_name(observed_xr, "bucket")), - spec = mbucketv1beta1.Spec( - forProvider = mbucketv1beta1.ForProvider( - location = params.location, - versioning = { - "enabled": params.versioning - } - ), - ), - ) - resource.update(rsp.desired.resources["bucket"], desired_bucket) - - # Bucket ACL - desired_acl = maclv1beta1.BucketACL( - metadata = default_metadata(resource_name(observed_xr, "acl")), - spec = maclv1beta1.Spec( - forProvider = maclv1beta1.ForProvider( - predefinedAcl=params.acl, - bucketSelector = maclv1beta1.BucketSelector(matchControllerRef = True) - ), - ), - ) - resource.update(rsp.desired.resources["acl"], desired_acl) + + -``` +For the full Go templating guide, including the bucket ownership, public +access block, and encryption resources this example skips, see [Create a +composition with Go Templates][go-template-compositions]. - Save your composition file. ## Review your function @@ -1522,3 +1078,5 @@ the built-in test suite. [up-cli]: /manuals/cli/overview [kubectl-installed]: https://kubernetes.io/docs/tasks/tools/ [docker-desktop]: https://www.docker.com/products/docker-desktop/ +[go-compositions]: /manuals/cli/howtos/compositions/go +[go-template-compositions]: /manuals/cli/howtos/compositions/go-template diff --git a/docs/getstarted/builders-workshop/4-deployment.md b/docs/getstarted/builders-workshop/4-deployment.md index ea83d9530..33a32c940 100644 --- a/docs/getstarted/builders-workshop/4-deployment.md +++ b/docs/getstarted/builders-workshop/4-deployment.md @@ -244,13 +244,33 @@ kubectl apply --filename examples/storagebucket/example.yaml Return the resource state with the up CLI. ```shell -kubectl get storagebuckets.platform.example.com -o yaml +kubectl get f examples/storagebucket/example.yaml -o yaml +``` + + +```shell-noCopy +NAME SYNCED READY COMPOSITION AGE +webapp True True app-yaml 56s +``` + + + + +```shell {copy-lines="1"} + +kubectl get deploy,service -l crossplane.io/composite=webapp +NAME READY UP-TO-DATE AVAILABLE AGE +deployment.apps/webapp-2r2rk 2/2 2 2 11m + +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +service/webapp-xfkzg ClusterIP 10.96.148.56 8080/TCP 11m ``` Now, you can validate your results through the Upbound Console, and make any changes to test your resources required. + ## Clean up Be sure to destroy your resources to avoid cloud costs: @@ -270,8 +290,8 @@ up project stop You just created an Upbound project from scratch with an embedded function and a resource claim. -Next, try out an Intelligent Control Plane solution or build your own Internal -Developer Platform. +Continue the workshop with [Build and push your first +Configuration][build-and-push] to package and share your control plane. For more information on projects and how to build control planes, checkout the [CLI Build][build] manuals. @@ -284,3 +304,4 @@ Build][build] manuals. [kubectl-installed]: https://kubernetes.io/docs/tasks/tools/ [docker-desktop]: https://www.docker.com/products/docker-desktop/ [build]: /manuals/cli/howtos/project +[build-and-push]: /getstarted/builders-workshop/build-and-push diff --git a/docs/getstarted/introduction/build-and-push.md b/docs/getstarted/builders-workshop/5-build-and-push.md similarity index 85% rename from docs/getstarted/introduction/build-and-push.md rename to docs/getstarted/builders-workshop/5-build-and-push.md index 0c9fc0078..760829578 100644 --- a/docs/getstarted/introduction/build-and-push.md +++ b/docs/getstarted/builders-workshop/5-build-and-push.md @@ -1,6 +1,6 @@ --- -title: Build and push your first Configuration -sidebar_position: 3 +title: 5. Build and push your first Configuration +description: Package your control plane and share it via the Upbound Marketplace --- @@ -25,7 +25,8 @@ Before you begin, make sure you have: * The `up` CLI installed * A control plane project ready to package -Read the [Create a Control Plane][create] quickstart if you haven't yet. +If you missed any of the previous steps, go to the [project +foundations][project-foundations] guide to get started. ## Sign in to Upbound @@ -109,10 +110,11 @@ extensions. ## Next steps -Read the [What's Next][whats-next] to continue your learning journey. +Continue the workshop with [Create an AI-augmented operation][integrate-ai] to +add AI-powered monitoring to your control plane. -[create]: /getstarted/introduction/project +[project-foundations]: /getstarted/builders-workshop/project-foundations [configurations]: /manuals/uxp/concepts/packages/configurations [repositories]: /manuals/marketplace/repositories/overview [marketplace]: https://marketplace.upbound.io/ -[whats-next]: /getstarted/introduction/whats-next +[integrate-ai]: /getstarted/builders-workshop/integrate-ai diff --git a/docs/getstarted/introduction/integrate-ai.md b/docs/getstarted/builders-workshop/6-integrate-ai.md similarity index 96% rename from docs/getstarted/introduction/integrate-ai.md rename to docs/getstarted/builders-workshop/6-integrate-ai.md index 6c9a25d3c..30187736e 100644 --- a/docs/getstarted/introduction/integrate-ai.md +++ b/docs/getstarted/builders-workshop/6-integrate-ai.md @@ -1,7 +1,6 @@ --- -title: Create an AI-augmented operation +title: 6. Create an AI-augmented operation description: "Use Upbound Crossplane to build and manage an AI-powered control plane" -sidebar_position: 2 --- @@ -29,7 +28,7 @@ practitioners. Before you begin, make sure you have: -* a defined project from the [previous guide][project] +* a defined project from the [project foundations][project-foundations] guide * an Anthropic API key for Claude AI integration * `kubectl` access to your Kubernetes cluster * the [Upbound CLI][up] installed and configured @@ -122,7 +121,7 @@ subjects: name: function-analysis-gate namespace: crossplane-system --- -# crossplane needs permissions to manage Analyses for correspinding +# crossplane needs permissions to manage Analyses for corresponding # WatchOperations. apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole @@ -173,7 +172,7 @@ subjects: name: function-remediation-gate namespace: crossplane-system --- -# crossplane needs permissions to watch Remediations for correspinding +# crossplane needs permissions to watch Remediations for corresponding # WatchOperations. apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole @@ -191,7 +190,7 @@ rules: - list - watch --- -# crossplane needs permissions to watch Remediations for correspinding +# crossplane needs permissions to watch Remediations for corresponding # WatchOperations. apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole @@ -608,15 +607,14 @@ remediation steps. ## Next steps -Now that your control plane is running locally with AI-powered operations, consider these next steps: - -* Package your control plane as a [Configuration][Configuration] image and push it to the Upbound Marketplace -* Complete the [Build and push your first Configuration][buildAndPush] tutorial -* Explore additional [AI-powered operations][aiOperations] for other infrastructure scenarios +Now that your control plane is running locally with AI-powered operations, +explore additional [AI-powered operations][aiOperations] for other +infrastructure scenarios, or try out an [Intelligent Control Plane +solution][intelligentControlPlanes]. [up]: /manuals/cli/overview -[project]: /getstarted/introduction/project +[project-foundations]: /getstarted/builders-workshop/project-foundations [anthropic-key]: https://docs.anthropic.com/en/api/admin-api/apikeys/get-api-key [Configuration]: /manuals/uxp/concepts/packages/configurations [aiOperations]: /manuals/uxp/concepts/operations/intelligent-operations/ -[buildAndPush]: /getstarted/introduction/build-and-push +[intelligentControlPlanes]: /guides/intelligent-control-planes/ diff --git a/docs/getstarted/builders-workshop/_category_.json b/docs/getstarted/builders-workshop/_category_.json index 960ed02c2..c3c03baa3 100644 --- a/docs/getstarted/builders-workshop/_category_.json +++ b/docs/getstarted/builders-workshop/_category_.json @@ -1,4 +1,4 @@ { "label": "Builder's Workshop", - "position": 5 + "position": 4 } diff --git a/docs/getstarted/develop-with-ai.md b/docs/getstarted/develop-with-ai.md index 580fdb031..28853b0b9 100644 --- a/docs/getstarted/develop-with-ai.md +++ b/docs/getstarted/develop-with-ai.md @@ -1,6 +1,7 @@ --- title: Develop with AI description: Connect AI coding assistants and AI operations to Upbound using MCP servers. +sidebar_position: 5 --- import Tabs from '@theme/Tabs'; diff --git a/docs/getstarted/introduction/_category_.json b/docs/getstarted/introduction/_category_.json deleted file mode 100644 index 69cc2d008..000000000 --- a/docs/getstarted/introduction/_category_.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "label": "Introduction", - "position": 4 -} diff --git a/docs/getstarted/introduction/project.md b/docs/getstarted/introduction/project.md deleted file mode 100644 index 8ece55d4d..000000000 --- a/docs/getstarted/introduction/project.md +++ /dev/null @@ -1,1139 +0,0 @@ ---- -title: Create a control plane project -sidebar_position: 1 ---- - -Now that you have an Upbound account and the up CLI installed, you're ready to -create a control plane. - - - -In this quickstart, you will: - - - -1. Scaffold a control plane project -2. Define your own resource abstraction and templatization -3. See the changes immediately - -:::tip - -This quickstart teaches how to use Crossplane to build workflows for templating -resources and exposing them as simplified resource abstraction. If you just want -to manage the lifecycle of resources in an external system through Crossplane -and Kubernetes, read [Manage external resources with providers][providers] - -::: - -## Prerequisites - -This quickstart takes around 10 minutes to complete. You should be familiar with -YAML or programming in Go, Python, or KCL. - -Before beginning, make sure you have: - -- The [up][up-cli] CLI installed -- A Docker-compatible container runtime installed and running on your system -- A free Community [Upbound account][register] - -:::tip -**Podman users** -If you're using Podman instead of Docker, set the `DOCKER_HOST` environment variable to the Podman socket before running `up` commands: - -```shell -export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock -``` -::: - -## Login to Upbound - -The `up` CLI requires you to log in to run local control planes. You can [register] for a free Community account. - -In your terminal, run the following command: - -```shell -up login -``` -Your browser opens and prompts you to log in to your organization. Once you're authenticated, you can close the page. - -## Create a control plane project - -Crossplane works by letting you define new resource types in Kubernetes that -invoke function pipelines to template and generate other resources. Just like -any other software project, a _control plane project_ is a source-level -representation of your control plane. - -Create a control plane project on your machine by running the following command: - -```shell -up project init --scratch getting-started -``` - -This scaffolds a new project in a folder called `getting-started`. Change your -current working directory to the project root folder: - -```shell -cd getting-started -``` - -## Deploy your control plane - -In the root directory of your project, build and run your project by running the -following: - -```shell -up project run --local --ingress -``` - -This launches an instance of Upbound Crossplane on your machine, wrapped and -deployed in a container. Upbound Crossplane comes bundled with a Web UI. - -![image][webUI] - -## Define your own resource type - -Customize your control plane by defining your own resource type. - -Create an example instance of your custom resource type with: - -```shell -up example generate \ - --api-group platform.example.com \ - --api-version v1alpha1 \ - --kind WebApp\ - --name webapp \ - --scope namespace \ - --namespace default -``` - -Open the project in your IDE of choice and replace the contents of the generated file -`getting-started/examples/webapp/webapp.yaml` with the following: - -```yaml title="getting-started/examples/webapp/webapp.yaml" -apiVersion: platform.example.com/v1alpha1 -kind: WebApp -metadata: - name: webapp - namespace: default -spec: - parameters: - image: nginx - port: 8080 - replicas: 1 - service: - enabled: true - ingress: - enabled: false - serviceAccount: default - resources: - requests: - memory: "64Mi" - cpu: "250m" - limits: - memory: "1Gi" - cpu: "1" -status: - availableReplicas: 1 - url: "http://localhost:8080" -``` - -Next, generate the definition files needed by Crossplane with the following commands: - - - - -```shell -up xrd generate examples/webapp/webapp.yaml -up composition generate apis/webapps/definition.yaml -up function generate --language=go-templating compose-resources apis/webapps/composition.yaml -up dependency add --api k8s:v1.33.0 -``` - - -```shell -up xrd generate examples/webapp/webapp.yaml -up composition generate apis/webapps/definition.yaml -up function generate --language=python compose-resources apis/webapps/composition.yaml -up dependency add --api k8s:v1.33.0 -``` - - -```shell -up xrd generate examples/webapp/webapp.yaml -up composition generate apis/webapps/definition.yaml -up function generate --language=go compose-resources apis/webapps/composition.yaml -up dependency add --api k8s:v1.33.0 -``` - - -```shell -up xrd generate examples/webapp/webapp.yaml -up composition generate apis/webapps/definition.yaml -up function generate --language=kcl compose-resources apis/webapps/composition.yaml -up dependency add --api k8s:v1.33.0 -``` - - - -You just created your own resource type called `WebApp`. You generated a function -containing the logic Crossplane uses to determine what should happen when you -create the `WebApp`. - -:::tip - -To define a new resource type with Crossplane, you need to: - -* create a [CompositeResourceDefinition (XRD)][xrd], which defines the API schema of your resource type -* create a [Composition][composition], which defines the implementation of that API schema. - -A Composition is a pipeline of [functions][functions], which contain the user-defined logic of your composition. - -::: - -Open the function definition file at -`getting-started/functions/compose-resources/` and replace the contents with the -following: - - - - - -```yaml title="getting-started/functions/compose-resources/01-compose.yaml.gotmpl" -# code: language=yaml -# yaml-language-server: $schema=../../.up/json/models/index.schema.json - ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - annotations: - gotemplating.fn.crossplane.io/composition-resource-name: deployment - {{ if eq (.observed.resources.deployment | getResourceCondition "Available").Status "True" }} - gotemplating.fn.crossplane.io/ready: "True" - {{ end }} - name: {{ .observed.composite.resource.metadata.name }} - namespace: {{ .observed.composite.resource.metadata.namespace }} - labels: - app.kubernetes.io/name: {{ .observed.composite.resource.metadata.name }} -spec: - replicas: {{ .observed.composite.resource.spec.parameters.replicas }} - selector: - matchLabels: - app.kubernetes.io/name: {{ .observed.composite.resource.metadata.name }} - app: {{ .observed.composite.resource.metadata.name }} - strategy: {} - template: - metadata: - labels: - app.kubernetes.io/name: {{ .observed.composite.resource.metadata.name }} - app: {{ .observed.composite.resource.metadata.name }} - spec: - serviceAccountName: {{ .observed.composite.resource.spec.parameters.serviceAccount }} - containers: - - name: {{ .observed.composite.resource.metadata.name }} - image: {{ .observed.composite.resource.spec.parameters.image }} - imagePullPolicy: Always - ports: - - name: http - containerPort: {{ .observed.composite.resource.spec.parameters.port }} - protocol: TCP - resources: - requests: - memory: "64Mi" - cpu: "250m" - limits: - memory: "1Gi" - cpu: "1" - restartPolicy: Always -status: {} -# code: language=yaml -# yaml-language-server: $schema=../../.up/json/models/index.schema.json - -{{ if .observed.composite.resource.spec.parameters.ingress.enabled }} ---- -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - annotations: - gotemplating.fn.crossplane.io/composition-resource-name: ingress - {{ if (get (getComposedResource . "ingress").status.loadBalancer.ingress 0).hostname }} - gotemplating.fn.crossplane.io/ready: "True" - {{ end }} - kubernetes.io/ingress.class: alb - alb.ingress.kubernetes.io/scheme: internet-facing - alb.ingress.kubernetes.io/target-type: ip - alb.ingress.kubernetes.io/healthcheck-path: /health - alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}]' - alb.ingress.kubernetes.io/target-group-attributes: stickiness.enabled=true,stickiness.lb_cookie.duration_seconds=60 - name: {{ .observed.composite.resource.metadata.name }} - namespace: {{ .observed.composite.resource.metadata.namespace }} -spec: - rules: - - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: {{ .observed.composite.resource.metadata.name }} - port: - number: 80 -{{ end }} -# code: language=yaml -# yaml-language-server: $schema=../../.up/json/models/index.schema.json - -{{ if .observed.composite.resource.spec.parameters.service.enabled }} ---- -apiVersion: v1 -kind: Service -metadata: - annotations: - gotemplating.fn.crossplane.io/composition-resource-name: service - {{ if (get (getComposedResource . "service").spec "clusterIP") }} - gotemplating.fn.crossplane.io/ready: "True" - {{ end }} - name: {{ .observed.composite.resource.metadata.name }} - namespace: {{ .observed.composite.resource.metadata.namespace }} -spec: - selector: - app: {{ .observed.composite.resource.metadata.name }} - ports: - - name: http - protocol: TCP - port: 80 - targetPort: http -status: - loadBalancer: {} -{{ end }} -# code: language=yaml -# yaml-language-server: $schema=../../.up/json/models/index.schema.json - ---- -apiVersion: {{ .observed.composite.resource.apiVersion }} -kind: {{ .observed.composite.resource.kind }} -status: - {{ with $deployment := getComposedResource . "deployment" }} - deploymentConditions: {{ $deployment.status.conditions | toJson }} - availableReplicas: {{ $deployment.status.availableReplicas | default 0 }} - {{ else }} - deploymentConditions: [] - availableReplicas: 0 - {{ end }} - {{ with $ingress := getComposedResource . "ingress" }} - {{ with $hostname := (get $ingress.status.loadBalancer.ingress 0).hostname }} - url: {{ $hostname | quote }} - {{ else }} - url: "" - {{ end }} - {{ else }} - url: "" - {{ end }} -``` - - - - -```python title="getting-started/functions/compose-resources/main.py" -from crossplane.function import resource -from crossplane.function.proto.v1 import run_function_pb2 as fnv1 -from .model.io.k8s.api.apps import v1 as appsv1 -from .model.io.k8s.api.core import v1 as corev1 -from .model.io.k8s.api.networking import v1 as networkingv1 -from .model.com.example.platform.webapp import v1alpha1 as platformv1alpha1 -from .model.io.k8s.apimachinery.pkg.apis.core.meta import v1 as coremetav1 -def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): - oxr = platformv1alpha1.WebApp(**req.observed.composite.resource) - ocds = req.observed.resources - - # Create a Status object to collect updates - status = platformv1alpha1.Status() - - deployment = appsv1.Deployment( - metadata=coremetav1.ObjectMeta( - name=oxr.metadata.name, - namespace=oxr.metadata.namespace, - labels={ - "app.kubernetes.io/name": oxr.metadata.name - }, - ), - spec=appsv1.DeploymentSpec( - replicas=oxr.spec.parameters.replicas, - selector=coremetav1.LabelSelector( - matchLabels={ - "app.kubernetes.io/name": oxr.metadata.name, - "app": oxr.metadata.name - } - ), - template=corev1.PodTemplateSpec( - metadata=coremetav1.ObjectMeta( - labels={ - "app.kubernetes.io/name": oxr.metadata.name, - "app": oxr.metadata.name - } - ), - spec=corev1.PodSpec( - serviceAccountName=oxr.spec.parameters.serviceAccount, - containers=[ - corev1.Container( - name=oxr.metadata.name, - image=oxr.spec.parameters.image, - imagePullPolicy="Always", - ports=[ - corev1.ContainerPort( - name="http", - containerPort=int(oxr.spec.parameters.port), - protocol="TCP", - ) - ], - resources=corev1.ResourceRequirements( - requests={ - "memory": "64Mi", - "cpu": "250m" - }, - limits={ - "memory": "1Gi", - "cpu": "1" - } - ) - ) - ], - restartPolicy="Always" - ) - ) - ) - ) - - if "deployment" in ocds: - observed_deployment = appsv1.Deployment(**ocds["deployment"].resource) - if observed_deployment.status and observed_deployment.status.conditions: - for condition in observed_deployment.status.conditions: - if condition.type == "Available" and condition.status == "True": - rsp.desired.resources["deployment"].ready = True - break - - resource.update(rsp.desired.resources["deployment"], deployment) - - if oxr.spec.parameters.service and oxr.spec.parameters.service.enabled: - service = corev1.Service( - metadata=coremetav1.ObjectMeta( - name=oxr.metadata.name, - namespace=oxr.metadata.namespace, - ), - spec=corev1.ServiceSpec( - selector={ - "app": oxr.metadata.name - }, - ports=[ - corev1.ServicePort( - name="http", - protocol="TCP", - port=80, - targetPort="http" - ) - ] - ) - ) - - if "service" in ocds: - observed_service = corev1.Service(**ocds["service"].resource) - if observed_service.spec and observed_service.spec.clusterIP: - rsp.desired.resources["service"].ready = True - resource.update(rsp.desired.resources["service"], service) - - if oxr.spec.parameters.ingress and oxr.spec.parameters.ingress.enabled: - ingress = networkingv1.Ingress( - metadata=coremetav1.ObjectMeta( - name=oxr.metadata.name, - namespace=oxr.metadata.namespace, - annotations={ - "kubernetes.io/ingress.class": "alb", - "alb.ingress.kubernetes.io/scheme": "internet-facing", - "alb.ingress.kubernetes.io/target-type": "ip", - "alb.ingress.kubernetes.io/healthcheck-path": "/health", - "alb.ingress.kubernetes.io/listen-ports": '[{"HTTP": 80}]', - "alb.ingress.kubernetes.io/target-group-attributes": "stickiness.enabled=true,stickiness.lb_cookie.duration_seconds=60" - } - ), - spec=networkingv1.IngressSpec( - rules=[ - networkingv1.IngressRule( - http=networkingv1.HTTPIngressRuleValue( - paths=[ - networkingv1.HTTPIngressPath( - path="/", - pathType="Prefix", - backend=networkingv1.IngressBackend( - service=networkingv1.IngressServiceBackend( - name=oxr.metadata.name, - port=networkingv1.ServiceBackendPort( - number=80 - ) - ) - ) - ) - ] - ) - ) - ] - ) - ) - - if "ingress" in ocds: - observed_ingress = networkingv1.Ingress(**ocds["ingress"].resource) - if (observed_ingress.status and - observed_ingress.status.loadBalancer and - observed_ingress.status.loadBalancer.ingress and - len(observed_ingress.status.loadBalancer.ingress) > 0 and - observed_ingress.status.loadBalancer.ingress[0].hostname): - rsp.desired.resources["ingress"].ready = True - resource.update(rsp.desired.resources["ingress"], ingress) - - # Set status with defaults - if "deployment" in ocds: - observed_deployment = appsv1.Deployment(**ocds["deployment"].resource) - if observed_deployment.status and observed_deployment.status.conditions: - status.deploymentConditions = [] - for condition in observed_deployment.status.conditions: - condition_dict = condition.model_dump(exclude_none=True) - # Convert datetime objects to ISO format strings - if 'lastTransitionTime' in condition_dict and condition_dict['lastTransitionTime']: - condition_dict['lastTransitionTime'] = condition_dict['lastTransitionTime'].isoformat() - if 'lastUpdateTime' in condition_dict and condition_dict['lastUpdateTime']: - condition_dict['lastUpdateTime'] = condition_dict['lastUpdateTime'].isoformat() - status.deploymentConditions.append(condition_dict) - else: - status.deploymentConditions = [] - status.availableReplicas = observed_deployment.status.availableReplicas if observed_deployment.status and observed_deployment.status.availableReplicas else 0 - else: - status.deploymentConditions = [] - status.availableReplicas = 0 - - if "ingress" in ocds: - observed_ingress = networkingv1.Ingress(**ocds["ingress"].resource) - status.url = ( - observed_ingress.status.loadBalancer.ingress[0].hostname - if (observed_ingress.status and - observed_ingress.status.loadBalancer and - observed_ingress.status.loadBalancer.ingress and - len(observed_ingress.status.loadBalancer.ingress) > 0 and - observed_ingress.status.loadBalancer.ingress[0].hostname) - else "" - ) - else: - status.url = "" - - resource.update(rsp.desired.composite, {"status": status.model_dump(exclude_none=True)}) -``` - - - - -```go title="getting-started/functions/compose-resources/fn.go" -package main - -import ( - "context" - "encoding/json" - - "dev.upbound.io/models/com/example/platform/v1alpha1" - appsv1 "dev.upbound.io/models/io/k8s/apps/v1" - coremetav1 "dev.upbound.io/models/io/k8s/core/meta/v1" - corev1 "dev.upbound.io/models/io/k8s/core/v1" - networkingv1 "dev.upbound.io/models/io/k8s/networking/v1" - resourcev1 "dev.upbound.io/models/io/k8s/resource/v1" - "github.com/crossplane/crossplane-runtime/pkg/logging" - "github.com/crossplane/function-sdk-go/errors" - fnv1 "github.com/crossplane/function-sdk-go/proto/v1" - "github.com/crossplane/function-sdk-go/request" - "github.com/crossplane/function-sdk-go/resource" - "github.com/crossplane/function-sdk-go/resource/composed" - "github.com/crossplane/function-sdk-go/response" - "k8s.io/utils/ptr" -) - -// Function is your composition function. -type Function struct { - fnv1.UnimplementedFunctionRunnerServiceServer - - log logging.Logger -} - -// RunFunction runs the Function. -func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) { - f.log.Info("Running function", "tag", req.GetMeta().GetTag()) - rsp := response.To(req, response.DefaultTTL) - - observedComposite, err := request.GetObservedCompositeResource(req) - if err != nil { - response.Fatal(rsp, errors.Wrap(err, "cannot get xr")) - return rsp, nil - } - - observedComposed, err := request.GetObservedComposedResources(req) - if err != nil { - response.Fatal(rsp, errors.Wrap(err, "cannot get observed resources")) - return rsp, nil - } - - var xr v1alpha1.WebApp - if err := convertViaJSON(&xr, observedComposite.Resource); err != nil { - response.Fatal(rsp, errors.Wrap(err, "cannot convert xr")) - return rsp, nil - } - - params := xr.Spec.Parameters - if params == nil { - response.Fatal(rsp, errors.New("missing parameters")) - return rsp, nil - } - - // We'll collect our desired composed resources into this map, then convert - // them to the SDK's types and set them in the response when we return. - desiredComposed := make(map[resource.Name]any) - defer func() { - desiredComposedResources, err := request.GetDesiredComposedResources(req) - if err != nil { - response.Fatal(rsp, errors.Wrap(err, "cannot get desired resources")) - return - } - - for name, obj := range desiredComposed { - c := composed.New() - if err := convertViaJSON(c, obj); err != nil { - response.Fatal(rsp, errors.Wrapf(err, "cannot convert %s to unstructured", name)) - return - } - dc := &resource.DesiredComposed{Resource: c} - - // Check if this resource should be marked as ready - if c.GetAnnotations()["go.upbound.io/ready"] == "True" { - dc.Ready = resource.ReadyTrue - } - - desiredComposedResources[name] = dc - } - - if err := response.SetDesiredComposedResources(rsp, desiredComposedResources); err != nil { - response.Fatal(rsp, errors.Wrap(err, "cannot set desired resources")) - return - } - }() - - // Create Deployment - deployment := &appsv1.Deployment{ - APIVersion: ptr.To(appsv1.DeploymentAPIVersionAppsV1), - Kind: ptr.To(appsv1.DeploymentKindDeployment), - Metadata: &coremetav1.ObjectMeta{ - Name: xr.Metadata.Name, - Namespace: xr.Metadata.Namespace, - Labels: &map[string]string{ - "app.kubernetes.io/name": *xr.Metadata.Name, - }, - }, - Spec: &appsv1.DeploymentSpec{ - Replicas: ptr.To(int32(*params.Replicas)), - Selector: &coremetav1.LabelSelector{ - MatchLabels: &map[string]string{ - "app.kubernetes.io/name": *xr.Metadata.Name, - "app": *xr.Metadata.Name, - }, - }, - // ToDo(haarchri): remove this - Strategy: &appsv1.IoK8SApiAppsV1DeploymentStrategy{}, - Template: &corev1.PodTemplateSpec{ - Metadata: &coremetav1.ObjectMeta{ - Labels: &map[string]string{ - "app.kubernetes.io/name": *xr.Metadata.Name, - "app": *xr.Metadata.Name, - }, - }, - Spec: &corev1.PodSpec{ - ServiceAccountName: params.ServiceAccount, - Containers: &[]corev1.Container{{ - Name: xr.Metadata.Name, - Image: params.Image, - ImagePullPolicy: ptr.To("Always"), - Ports: &[]corev1.ContainerPort{{ - Name: ptr.To("http"), - ContainerPort: ptr.To(int32(*params.Port)), - Protocol: ptr.To("TCP"), - }}, - Resources: &corev1.ResourceRequirements{ - Requests: &map[string]resourcev1.Quantity{ - "memory": "64Mi", - "cpu": "250m", - }, - Limits: &map[string]resourcev1.Quantity{ - "memory": "1Gi", - "cpu": "1", - }, - }, - }}, - RestartPolicy: ptr.To("Always"), - }, - }, - }, - // ToDo(haarchri): remove this - Status: &appsv1.IoK8SApiAppsV1DeploymentStatus{}, - } - - // Check if deployment is ready - observedDeployment, ok := observedComposed["deployment"] - if ok && observedDeployment.Resource != nil { - var obsDeployment appsv1.Deployment - if err := convertViaJSON(&obsDeployment, observedDeployment.Resource); err == nil { - if obsDeployment.Status != nil && obsDeployment.Status.Conditions != nil { - for _, c := range *obsDeployment.Status.Conditions { - if c.Type != nil && *c.Type == "Available" && - c.Status != nil && *c.Status == "True" { - if deployment.Metadata.Annotations == nil { - deployment.Metadata.Annotations = &map[string]string{} - } - (*deployment.Metadata.Annotations)["go.upbound.io/ready"] = "True" - break - } - } - } - } - } - desiredComposed["deployment"] = deployment - - // Create Service if enabled - if params.Service != nil && params.Service.Enabled != nil && *params.Service.Enabled { - service := &corev1.Service{ - APIVersion: ptr.To(corev1.ServiceAPIVersionV1), - Kind: ptr.To(corev1.ServiceKindService), - Metadata: &coremetav1.ObjectMeta{ - Name: xr.Metadata.Name, - Namespace: xr.Metadata.Namespace, - }, - Spec: &corev1.ServiceSpec{ - Selector: &map[string]string{ - "app": *xr.Metadata.Name, - }, - Ports: &[]corev1.ServicePort{{ - Name: ptr.To("http"), - Protocol: ptr.To("TCP"), - Port: ptr.To(int32(80)), - TargetPort: ptr.To("http"), - }}, - }, - // ToDo(haarchri): remove this - Status: &corev1.ServiceStatus{ - LoadBalancer: &corev1.LoadBalancerStatus{}, - }, - } - - // Check if service is ready - observedService, ok := observedComposed["service"] - if ok && observedService.Resource != nil { - var obsService corev1.Service - if err := convertViaJSON(&obsService, observedService.Resource); err == nil { - if obsService.Spec != nil && obsService.Spec.ClusterIP != nil && *obsService.Spec.ClusterIP != "" { - if service.Metadata.Annotations == nil { - service.Metadata.Annotations = &map[string]string{} - } - (*service.Metadata.Annotations)["go.upbound.io/ready"] = "True" - } - } - } - desiredComposed["service"] = service - } - - // Create Ingress if enabled - if params.Ingress != nil && params.Ingress.Enabled != nil && *params.Ingress.Enabled { - ingress := &networkingv1.Ingress{ - APIVersion: ptr.To(networkingv1.IngressAPIVersionNetworkingK8SIoV1), - Kind: ptr.To(networkingv1.IngressKindIngress), - Metadata: &coremetav1.ObjectMeta{ - Name: xr.Metadata.Name, - Namespace: xr.Metadata.Namespace, - Annotations: &map[string]string{ - "kubernetes.io/ingress.class": "alb", - "alb.ingress.kubernetes.io/scheme": "internet-facing", - "alb.ingress.kubernetes.io/target-type": "ip", - "alb.ingress.kubernetes.io/healthcheck-path": "/health", - "alb.ingress.kubernetes.io/listen-ports": `[{"HTTP": 80}]`, - "alb.ingress.kubernetes.io/target-group-attributes": "stickiness.enabled=true,stickiness.lb_cookie.duration_seconds=60", - }, - }, - Spec: &networkingv1.IngressSpec{ - Rules: &[]networkingv1.IngressRule{{ - HTTP: &networkingv1.HTTPIngressRuleValue{ - Paths: &[]networkingv1.HTTPIngressPath{{ - Path: ptr.To("/"), - PathType: ptr.To("Prefix"), - Backend: &networkingv1.IngressBackend{ - Service: &networkingv1.IngressServiceBackend{ - Name: xr.Metadata.Name, - Port: &networkingv1.ServiceBackendPort{ - Number: ptr.To(int32(80)), - }, - }, - }, - }}, - }, - }}, - }, - } - - // Check if ingress is ready - observedIngress, ok := observedComposed["ingress"] - if ok && observedIngress.Resource != nil { - var obsIngress networkingv1.Ingress - if err := convertViaJSON(&obsIngress, observedIngress.Resource); err == nil { - if obsIngress.Status != nil && obsIngress.Status.LoadBalancer != nil && - obsIngress.Status.LoadBalancer.Ingress != nil && len(*obsIngress.Status.LoadBalancer.Ingress) > 0 { - firstIngress := (*obsIngress.Status.LoadBalancer.Ingress)[0] - if firstIngress.Hostname != nil && *firstIngress.Hostname != "" { - if ingress.Metadata.Annotations == nil { - ingress.Metadata.Annotations = &map[string]string{} - } - (*ingress.Metadata.Annotations)["go.upbound.io/ready"] = "True" - } - } - } - } - desiredComposed["ingress"] = ingress - } - - // Update XR status - desiredXR, err := request.GetDesiredCompositeResource(req) - if err != nil { - response.Fatal(rsp, errors.Wrap(err, "cannot get desired composite resource")) - return rsp, nil - } - - // Convert desired XR to WebApp - var desiredWebApp v1alpha1.WebApp - desiredWebApp.APIVersion = ptr.To(v1alpha1.WebAppAPIVersionplatformExampleComV1Alpha1) - desiredWebApp.Kind = ptr.To(v1alpha1.WebAppKindWebApp) - if err := convertViaJSON(&desiredWebApp, desiredXR.Resource); err != nil { - response.Fatal(rsp, errors.Wrap(err, "cannot convert desired xr")) - return rsp, nil - } - - // Update status fields - if desiredWebApp.Status == nil { - desiredWebApp.Status = &v1alpha1.WebAppStatus{} - } - - // Set deployment conditions - if observedDeployment, ok := observedComposed["deployment"]; ok && observedDeployment.Resource != nil { - var obsDeployment appsv1.Deployment - if err := convertViaJSON(&obsDeployment, observedDeployment.Resource); err == nil { - if obsDeployment.Status != nil { - if obsDeployment.Status.Conditions != nil { - deploymentConditions := []v1alpha1.WebAppStatusDeploymentConditionsItem{} - for _, c := range *obsDeployment.Status.Conditions { - condition := v1alpha1.WebAppStatusDeploymentConditionsItem{ - Type: c.Type, - Status: c.Status, - Message: c.Message, - Reason: c.Reason, - } - if c.LastUpdateTime != nil { - condition.LastUpdateTime = ptr.To(c.LastUpdateTime.String()) - } - if c.LastTransitionTime != nil { - condition.LastTransitionTime = ptr.To(c.LastTransitionTime.String()) - } - deploymentConditions = append(deploymentConditions, condition) - } - desiredWebApp.Status.DeploymentConditions = &deploymentConditions - } else { - // Set empty conditions if no conditions exist - deploymentConditions := []v1alpha1.WebAppStatusDeploymentConditionsItem{} - desiredWebApp.Status.DeploymentConditions = &deploymentConditions - } - if obsDeployment.Status.AvailableReplicas != nil { - desiredWebApp.Status.AvailableReplicas = ptr.To(int(*obsDeployment.Status.AvailableReplicas)) - } else { - // Set default value when no available replicas - desiredWebApp.Status.AvailableReplicas = ptr.To(0) - } - } else { - // Set defaults when status is nil - deploymentConditions := []v1alpha1.WebAppStatusDeploymentConditionsItem{} - desiredWebApp.Status.DeploymentConditions = &deploymentConditions - desiredWebApp.Status.AvailableReplicas = ptr.To(0) - } - } - } else { - // Set defaults when deployment doesn't exist - deploymentConditions := []v1alpha1.WebAppStatusDeploymentConditionsItem{} - desiredWebApp.Status.DeploymentConditions = &deploymentConditions - desiredWebApp.Status.AvailableReplicas = ptr.To(0) - } - - // Set ingress URL - if observedIngress, ok := observedComposed["ingress"]; ok && observedIngress.Resource != nil { - var obsIngress networkingv1.Ingress - if err := convertViaJSON(&obsIngress, observedIngress.Resource); err == nil { - if obsIngress.Status != nil && obsIngress.Status.LoadBalancer != nil && - obsIngress.Status.LoadBalancer.Ingress != nil && len(*obsIngress.Status.LoadBalancer.Ingress) > 0 { - firstIngress := (*obsIngress.Status.LoadBalancer.Ingress)[0] - if firstIngress.Hostname != nil { - desiredWebApp.Status.URL = firstIngress.Hostname - } else { - // Set empty string when hostname is nil - desiredWebApp.Status.URL = ptr.To("") - } - } else { - // Set empty string when no load balancer ingress - desiredWebApp.Status.URL = ptr.To("") - } - } else { - // Set empty string when conversion fails - desiredWebApp.Status.URL = ptr.To("") - } - } else { - // Set empty string when ingress doesn't exist - desiredWebApp.Status.URL = ptr.To("") - } - - // Convert back to unstructured - if err := convertViaJSON(desiredXR.Resource, &desiredWebApp); err != nil { - response.Fatal(rsp, errors.Wrap(err, "cannot convert desired webapp back to unstructured")) - return rsp, nil - } - - if err := response.SetDesiredCompositeResource(rsp, desiredXR); err != nil { - response.Fatal(rsp, errors.Wrap(err, "cannot set desired composite resource")) - return rsp, nil - } - - return rsp, nil -} - -func convertViaJSON(to, from any) error { - bs, err := json.Marshal(from) - if err != nil { - return err - } - return json.Unmarshal(bs, to) -} - -``` - - - -```yaml title="getting-started/functions/compose-resources/main.k" - -import models.io.k8s.api.apps.v1 as appsv1 -import models.io.k8s.api.core.v1 as corev1 -import models.io.k8s.api.networking.v1 as networkingv1 -import models.com.example.platform.v1alpha1 as platformv1alpha1 - -oxr = platformv1alpha1.WebApp{**option("params").oxr} # observed claim -_ocds = option("params").ocds # observed composed resources -_dxr = option("params").dxr # desired composite resource -dcds = option("params").dcds # desired composed resources - -_metadata = lambda name: str -> any { - { annotations = { "krm.kcl.dev/composition-resource-name" = name }} -} - -_desired_deployment = appsv1.Deployment{ - metadata: _metadata("deployment") | { - name: oxr.metadata.name - namespace: oxr.metadata.namespace - labels: { - "app.kubernetes.io/name": oxr.metadata.name - } - } - spec: { - replicas: oxr.spec.parameters.replicas - selector: { - matchLabels: { - "app.kubernetes.io/name": oxr.metadata.name - app: oxr.metadata.name - } - } - template: { - metadata: { - labels: { - "app.kubernetes.io/name": oxr.metadata.name - app: oxr.metadata.name - } - } - spec: { - serviceAccountName: oxr.spec.parameters.serviceAccount - containers: [{ - name: oxr.metadata.name - image: oxr.spec.parameters.image - imagePullPolicy: "Always" - ports: [{ - name: "http" - containerPort: int(oxr.spec.parameters.port) - protocol: "TCP" - }] - resources: { - requests: { - memory: "64Mi" - cpu: "250m" - } - limits: { - memory: "1Gi" - cpu: "1" - } - } - }] - restartPolicy: "Always" - } - } - } -} - -observed_deployment = option("params").ocds["deployment"]?.Resource -if any_true([c.type == "Available" and c.status == "True" for c in observed_deployment?.status?.conditions or []]): - _desired_deployment.metadata.annotations["krm.kcl.dev/ready"] = "True" - -if oxr.spec.parameters.service.enabled: - _desired_service = corev1.Service{ - metadata: _metadata("service") | { - name: oxr.metadata.name - namespace: oxr.metadata.namespace - } - spec: { - selector: { - app: oxr.metadata.name - } - ports: [{ - name: "http" - protocol: "TCP" - port: 80 - targetPort: "http" - }] - } - } - -observed_service = option("params").ocds["service"]?.Resource -if observed_service?.spec?.clusterIP: - _desired_service.metadata.annotations["krm.kcl.dev/ready"] = "True" - -if oxr.spec.parameters.ingress.enabled: - _desired_ingress = networkingv1.Ingress{ - metadata: _metadata("ingress") | { - name: oxr.metadata.name - namespace: oxr.metadata.namespace - annotations: { - "kubernetes.io/ingress.class": "alb" - "alb.ingress.kubernetes.io/scheme": "internet-facing" - "alb.ingress.kubernetes.io/target-type": "ip" - "alb.ingress.kubernetes.io/healthcheck-path": "/health" - "alb.ingress.kubernetes.io/listen-ports": '[{"HTTP": 80}]' - "alb.ingress.kubernetes.io/target-group-attributes": "stickiness.enabled=true,stickiness.lb_cookie.duration_seconds=60" - } - } - spec: { - rules: [{ - http: { - paths: [{ - path: "/" - pathType: "Prefix" - backend: { - service: { - name: oxr.metadata.name - port: { - number: 80 - } - } - } - }] - } - }] - } - } - - -observed_ingress = option("params").ocds["ingress"]?.Resource -if observed_ingress?.status?.loadBalancer?.ingress?[0]?.hostname: - _desired_ingress.metadata.annotations["krm.kcl.dev/ready"] = "True" - -_desired_xr = { - **option("params").dxr - status.deploymentConditions = observed_deployment?.status?.conditions or [] - status.availableReplicas = observed_deployment?.status?.availableReplicas or 0 - status.url = observed_ingress?.status?.loadBalancer?.ingress?[0]?.hostname or "" -} - - -items = [ - _desired_deployment, - _desired_service, - _desired_ingress, - _desired_xr -] -``` - - - - -Deploy the changes you made to your control plane: - -```shell -up project run --local --ingress -``` - -:::tip - -The `project run` command builds and deploys any changes. If you don't have a -control plane running yet, it creates one, otherwise it targets your existing -control plane. - -::: - -## Use the custom resource - -Your control plane now understands _WebApp_ resources. Create a _WebApp_: - -```shell -kubectl apply -f examples/webapp/webapp.yaml -``` - - -Check that the _WebApp_ is ready: - - - -:::note -Resource and deployment names are auto-generated and will differ from the example outputs below. -::: - - - -```shell {copy-lines="1"} -kubectl get -f examples/webapp/webapp.yaml -NAME SYNCED READY COMPOSITION AGE -webapp True True app-yaml 56s -``` - -Observe the _Deployment_ and _Service_ Crossplane created when you created the -_WebApp_: - - -```shell {copy-lines="1"} -kubectl get deploy,service -l crossplane.io/composite=webapp -NAME READY UP-TO-DATE AVAILABLE AGE -deployment.apps/webapp-2r2rk 2/2 2 2 11m - -NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE -service/webapp-xfkzg ClusterIP 10.96.148.56 8080/TCP 11m -``` - -## Next steps - -Now that you know the basics of building with Upbound, create an AI-augmented -operation to detect and remediate Kubernetes app workload errors. Read [Create an AI-augmented operation][integrate-ai]. - -[register]: https://accounts.upbound.io/register -[up-cli]: /manuals/cli/overview -[marketplace]: https://marketplace.upbound.io -[functions]: /manuals/uxp/concepts/composition/composite-resource-definitions -[providers]: https://upbound.io -[xrd]: /manuals/uxp/concepts/composition/composite-resource-definitions -[composition]: /manuals/uxp/concepts/composition/overview -[functions]: /manuals/uxp/concepts/composition/composite-resource-definitions -[webUI]: /img/uxp-webui.png -[integrate-ai]: /getstarted/introduction/integrate-ai diff --git a/docs/getstarted/introduction/whats-next.md b/docs/getstarted/introduction/whats-next.md deleted file mode 100644 index 3316e3355..000000000 --- a/docs/getstarted/introduction/whats-next.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -title: What's Next -sidebar_position: 4 ---- - - -Congratulations! You created your first control plane, defined -custom resource types, integrated AI-powered operations, and packaged everything -as a distributable Configuration. - - -Your local control plane now automatically provisions Kubernetes deployments -through your custom `App` resource, detects pod issues using AI analysis, and -suggests intelligent remediation steps. You built the foundation of a modern -infrastructure platform. - -## Keep building - -Gain more knowledge on with hands-on workshops and tutorials: - -**Keep learning with the Builder's Workshop**: [Build custom -composition functions][workshop], handle complex resource relationships, and master error -handling patterns. - -**Build intelligent control planes**: Learn to create [AI-augmented control planes][intelligent-control-planes] that adapt and self-heal. - - -**Create an internal developer platform**: Follow our [Internal Developer -Platform guide][idp-guide] to build comprehensive developer experiences. - -Dive deeper into these core concepts: - -- [Compositions][compositions]: Learn how resource templating and logic work -- [Project structure][project-structure]: Organize and manage your control plane code -- [Control plane architecture][control-planes]: Understand the deployment architecture - - -## Deploy to production - -When you're ready to share your control plane with your team: - -**Upbound Spaces**: [Managed hosting environment][spaces] that -handles operations for you. Choose [Cloud Spaces][control-planes] for true SaaS or Self-hosted -Spaces for your infrastructure. - -**Self-managed UXP**: [Deploy into your own Kubernetes -cluster][uxp] with full control. - - -## More resources - -- Join our [Slack community][slack] for support, best practices, and to share what you build. -- Follow our [blog][blog] for talks, workshops, and more guides. - - -## Next steps - -Explore additional resources and deepen your knowledge of Upbound Crossplane. - - -[workshop]: /getstarted/builders-workshop/project-foundations/ -[intelligent-control-planes]: /guides/intelligent-control-planes/ -[idp-guide]: /guides/solutions/get-started/ -[spaces]: /self-hosted-spaces/overview/ -[control-planes]: /self-hosted-spaces/concepts/control-planes/ -[uxp]: /manuals/uxp/overview/ -[compositions]: /manuals/uxp/concepts/composition/overview/ -[project-structure]: /manuals/cli/howtos/project/ -[slack]: https://slack.crossplane.io/ -[blog]: https://blog.upbound.io/ diff --git a/docs/getstarted/overview.md b/docs/getstarted/overview.md index cd8f2b024..7beb01d67 100644 --- a/docs/getstarted/overview.md +++ b/docs/getstarted/overview.md @@ -1,9 +1,14 @@ --- -title: Get Upbound +title: Welcome sidebar_position: 1 -slug: "/getstarted" +slug: "/" +pagination_prev: null +pagination_next: null --- -import { GetStarted } from '@site/src/components/GetStartedCallout'; +import CardGrid from '@site/src/components/CardGrid'; +import GetUpboundHero from '@site/src/components/GetUpboundHero'; + + Welcome to Upbound, the enterprise platform for Crossplane that helps you build @@ -21,55 +26,31 @@ reliability, performance, and developer experience. -## Install Upbound Crossplane +## Download the CLI -Install Upbound Crossplane (UXP) into a fresh Kubernetes cluster with Helm or -the `up` CLI. - -:::important -Already running open source Crossplane? Don't run the commands below — follow -the [Upgrade Guide][upgrade] instead. -::: - - - - - -```shell -helm repo add upbound-stable https://charts.upbound.io/stable && helm repo update -helm install crossplane --namespace crossplane-system --create-namespace upbound-stable/crossplane --devel -``` - - - - - - -Download the `up` CLI and install UXP into the cluster pointed to by your -current kubeconfig context. - -**Download the CLI:** +Install the [up][up] CLI to get Upbound's tooling on your machine. ```shell curl -sL "https://cli.upbound.io" | sh ``` -**Install Upbound Crossplane:** - -```shell -up uxp install -``` - - - - - +Find more installation methods on the [Up CLI installation guide][up]. - -## New to Crossplane and Upbound? - - +## Choose your path + + ## What is Upbound? @@ -93,17 +74,5 @@ define in your custom APIs. You define your resources and Upbound parses, connects with the service, and manages the lifecycle on your behalf. -## Next steps - - -* Follow the [introduction][intro] guide to get started building your own control plane. -* For OSS Crossplane users, follow the [Upgrade][upgrade] guide. - - -[upgrade]: /getstarted/upgrade-to-upbound/upgrade-to-uxp -[guides]: /guides -[register]: https://www.upbound.io/register/?utm_source=docs&utm_medium=cta&utm_campaign=docs_get_started [up]: /manuals/cli/overview -[pricing]: https://upbound.io/pricing -[intro]: /getstarted/introduction/project diff --git a/docs/getstarted/quickstart/_category_.json b/docs/getstarted/quickstart/_category_.json new file mode 100644 index 000000000..1edb8eaac --- /dev/null +++ b/docs/getstarted/quickstart/_category_.json @@ -0,0 +1,5 @@ +{ + "label": "Quickstart", + "position": 2, + "collapsed": false +} diff --git a/docs/getstarted/quickstart/connect-a-second-control-plane.md b/docs/getstarted/quickstart/connect-a-second-control-plane.md new file mode 100644 index 000000000..aafe1d822 --- /dev/null +++ b/docs/getstarted/quickstart/connect-a-second-control-plane.md @@ -0,0 +1,150 @@ +--- +title: 2. Connect a second control plane +sidebar_position: 3 +pagination_prev: null +pagination_next: null +--- +import CardGrid from '@site/src/components/CardGrid'; +import GetUpboundHero from '@site/src/components/GetUpboundHero'; + +This section adds a second kind cluster to the hub you installed in +[part one](install-the-hub.md), installs +`hub-connector` in it, and registers it with your running hub. + +## Create the second cluster + +Create a new kind cluster the hub API reaches over the +Docker network. + +```shell +kind create cluster --name hub-quickstart-extra +``` + +## Expose the hub API + +:::important +A production install routes all traffic through a Gateway. This step exists only +because you're connecting two kind clusters. +::: + +In the demo, `hub-core` and its token exchange are ClusterIP Services, reachable +only from inside the demo cluster. Add two NodePort Services alongside them so +the second cluster can connect. + +```shell +kubectl --context kind-hub-quickstart -n hub apply -f - <<'EOF' +apiVersion: v1 +kind: Service +metadata: + name: hub-core-nodeport +spec: + type: NodePort + selector: + app.kubernetes.io/name: hub-core + app.kubernetes.io/instance: hub + ports: + - name: http + port: 8080 + targetPort: http + nodePort: 30080 +--- +apiVersion: v1 +kind: Service +metadata: + name: hub-core-token-exchange-nodeport +spec: + type: NodePort + selector: + app.kubernetes.io/name: hub-core + app.kubernetes.io/instance: hub + ports: + - name: token-exchange + port: 8444 + targetPort: token-exchange + nodePort: 30444 +EOF +``` + +The selectors match the labels the `hub-core` Pods already carry, so these +Services route to the running API without changing the originals. + + +## Create a registration token + +Every control plane gets its own registration token. The connector presents that +token the first time it contacts the hub API, which tells the hub which control +plane the connector speaks for. + +1. Open the [control planes page](https://hub.127.0.0.1.nip.io:8443/infrastructure/control-planes) + and sign in as `admin` / `admin`. +2. Create a ControlPlane in the `default` realm and name it `extra`. +3. Copy the registration token. The hub displays it once. + +![The Register a Control Plane dialog with realm and name fields](/img/quickstart/register-control-plane.png) + +4. Save it for the next step: + +```shell +export HUB_EXTRA_CTP_TOKEN= +``` + +## Install hub-connector + +The connector dials `hub-quickstart-control-plane:30080` for the API and +`:30444` for token exchange. Docker resolves that hostname to the demo cluster's +control plane container with its embedded DNS. + +```shell +kubectl --context kind-hub-quickstart-extra create namespace hub + +kubectl --context kind-hub-quickstart-extra -n hub create secret generic hub-connector-credentials \ + --from-literal=registrationToken="$HUB_EXTRA_CTP_TOKEN" + +helm install hub-connector oci://xpkg.upbound.io/upbound/hub-connector \ + --kube-context kind-hub-quickstart-extra \ + --version 1.0.0 \ + --namespace hub \ + --set connector.hub.url=http://hub-quickstart-control-plane:30080 \ + --set connector.hub.tokenExchangeUrl=http://hub-quickstart-control-plane:30444 \ + --set connector.hub.allowInsecure=true \ + --set connector.credentials.existingSecretRef.name=hub-connector-credentials \ + --wait --timeout 2m +``` + +Refresh the hub and confirm the second cluster is online. + +Navigate to the [resources +page](https://hub.127.0.0.1.nip.io:8443/explore/resources). You can filter by +control plane to only lists resources in your `default` or `extra` control +plane. + + +In the [control planes page](https://hub.127.0.0.1.nip.io:8443/infrastructure/control-planes) + lists `extra` next to `default` with a `Ready` status. + +![The control planes page listing default and extra, both Ready](/img/quickstart/control-planes-list.png) + +Select `extra` to see its resource counts and details: + +![The extra control plane detail panel with claim, composite, and managed resource counts](/img/quickstart/control-plane-extra.png) + +## Next steps + + + +If you're stopping here, follow [Clean up](/getstarted/quickstart/create-resources#clean-up) +to delete what you created. + + +- [Builders workshop](/getstarted/builders-workshop/project-foundations) for real cloud + resources. +- Explore the [hub][hub] for the centralized management installation. + +[hub]: /hub/ +[workshop]: /getstarted/builders-workshop/project-foundations diff --git a/docs/getstarted/quickstart/create-resources.md b/docs/getstarted/quickstart/create-resources.md new file mode 100644 index 000000000..ae2979475 --- /dev/null +++ b/docs/getstarted/quickstart/create-resources.md @@ -0,0 +1,224 @@ +--- +title: 3. Create a composite resource +sidebar_position: 4 +pagination_prev: null +pagination_next: null +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +The second cluster reports to the hub but runs no Crossplane resources. In this +part, you install UXP and a Configuration on it, then create a composite +resource. The hub shows the composite resource and the managed resource it +composes. + +Complete [part two](connect-a-second-control-plane.md) first. + +## Install UXP on the second cluster + +A Configuration needs a Crossplane control plane to run on. Make sure your +`kubectl` config uses the new control plane: + +```shell +kubectl config use-context kind-hub-quickstart-extra +``` + + + + +Install UXP with the `up` CLI: + +```shell +up uxp install +``` + + + + +Install UXP with `helm`. The `upbound-stable/crossplane` chart is UXP, the same +distribution the `up` CLI installs: + +```shell +helm repo add upbound-stable https://charts.upbound.io/stable && helm repo update + +helm install uxp upbound-stable/crossplane \ + --namespace crossplane-system --create-namespace --devel --wait +``` + + + + +## Install a Configuration + +A Configuration is a package containing custom APIs and the compositions behind +them. This one, `configuration-app`, offers an `App` API that deploys a Ghost +blog as a Helm release. + + + + +```shell +up ctp configuration install xpkg.upbound.io/upbound/configuration-app:v2.1.0 \ + --name configuration-app --wait 5m +``` + + + + +```shell +kubectl apply -f - <<'EOF' +apiVersion: pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: configuration-app +spec: + package: xpkg.upbound.io/upbound/configuration-app:v2.1.0 +EOF + +kubectl wait --for=condition=healthy configuration/configuration-app --timeout=5m +``` + + + + +Installing the Configuration also installs what it depends on, including +`provider-helm`. Wait for that provider to become healthy: + +```shell +kubectl wait --for=condition=healthy provider/upbound-provider-helm --timeout=5m +``` + + +## Configure the Helm provider + + +`provider-helm` needs a `ProviderConfig`, permission to create resources, and the +database credentials the Ghost chart reads: + +```shell +kubectl apply -f - <<'EOF' +apiVersion: helm.m.crossplane.io/v1beta1 +kind: ProviderConfig +metadata: + name: default + namespace: default +spec: + credentials: + source: InjectedIdentity +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: crossplane-provider-helm-cluster-admin +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-admin +subjects: + - kind: Group + apiGroup: rbac.authorization.k8s.io + name: system:serviceaccounts:crossplane-system +--- +apiVersion: v1 +kind: Secret +metadata: + name: ghost-db + namespace: default +stringData: + host: mariadb.default.svc.cluster.local + username: ghost + password: ghost +EOF +``` + +:::warning +This grants `cluster-admin` to Crossplane's ServiceAccounts to keep the +quickstart short. Scope the provider's permissions down in a real cluster. +::: + +## Create a composite resource + +Create an `App`. Crossplane composes a Helm `Release` managed resource from it. + +```shell +kubectl apply -f - <<'EOF' +apiVersion: platform.upbound.io/v1alpha1 +kind: App +metadata: + name: quickstart + namespace: default +spec: + parameters: + providerConfigName: default + helm: + chart: + name: ghost + repo: "oci://registry-1.docker.io/bitnamicharts" + version: 25.0.4 + wait: false + passwordSecretRef: + namespace: default + name: ghost-db +EOF +``` + +Both resources become ready in a couple of minutes: + +```shell +kubectl get app,releases.helm.m.crossplane.io -n default +``` + +```shell {title="Expected output"} +NAME SYNCED READY COMPOSITION AGE +app.platform.upbound.io/quickstart True True apps.platform.upbound.io 2m + +NAME CHART VERSION SYNCED READY STATE REVISION DESCRIPTION AGE +release.helm.m.crossplane.io/quickstart-f5d6f18853b0 ghost 25.0.4 True True deployed 1 Install complete 2m +``` + +## See it in the hub + +Open the [resources page](https://hub.127.0.0.1.nip.io:8443/explore/resources). +The `quickstart` App appears there, reported by the connector you installed in +part two. + +![Resources page listing the quickstart App](/img/quickstart/app-resource.png) + +On the control planes page, select `extra`. Its overview counts what the control +plane runs: one composite resource and the managed resources behind it. + +![Overview of the extra control plane, counting one composite resource and three managed resources](/img/quickstart/control-plane-overview.png) + +Switch to the **Resources** tab, where the `Type` column labels the `quickstart` +App as an `XR` and the `Release` it composes as an `MR`: + +![Resources tab for the extra control plane, with the App labeled XR and the Release labeled MR](/img/quickstart/control-plane-resources.png) + +Under the **Packages** tab, you see what you installed: the Configuration, the +`provider-helm` it depends on, and the functions behind the composition. See +[Packages][packages] for how Insights correlates package versions across control +planes. + +![Packages page listing configuration-app, provider-helm, and two functions](/img/quickstart/packages.png) + + +## Clean up + +```shell +kubectl delete app quickstart -n default +kind delete cluster --name hub-quickstart-extra +kind delete cluster --name hub-quickstart +``` + +## Next steps + +- [Insights][insights] for everything the **Explore** pages can do. +- [Builders workshop](/getstarted/builders-workshop/project-foundations) for real cloud + resources. +- Explore the [hub][hub] for the centralized management installation. + +[hub]: /hub/ +[insights]: /hub/products/insights/overview +[packages]: /hub/products/insights/packages +[workshop]: /getstarted/builders-workshop/project-foundations diff --git a/docs/getstarted/quickstart/install-the-hub.md b/docs/getstarted/quickstart/install-the-hub.md new file mode 100644 index 000000000..912f56f66 --- /dev/null +++ b/docs/getstarted/quickstart/install-the-hub.md @@ -0,0 +1,187 @@ +--- +title: 1. Install the hub +sidebar_position: 2 +pagination_prev: null +pagination_next: null +--- +import CardGrid from '@site/src/components/CardGrid'; +import GetUpboundHero from '@site/src/components/GetUpboundHero'; + + + +This three-part quickstart runs Upbound on your laptop. Part one installs the hub and the Console. [Part +two][parttwo] connects a second control plane. [Part three][partthree] creates a +managed resource and watches it show up in the hub. + + +This process takes up to 20 minutes and runs on local kind clusters. At any +point, you can cleanly uninstall everything you created. + + +## What the hub does + + +The hub gives you an API and Console for control planes you already run. Register +your control planes with the hub through `hub-connector` and see all your +resources across every control plane in one place. + +UXP is Upbound's Crossplane distribution, adding a secrets proxy and backup and +restore. Spaces run many control planes on shared infrastructure instead of one +cluster each. This quickstart uses UXP in part three. + +## Prerequisites + +Before you begin, make sure you have: +- `kind` +- `kubectl` +- `helm` version `v3.8.0` or later +- the [up CLI][up] to install UXP. + + + +:::important +The hub installation in this quickstart is free to try from July +31st, 2026 to October 29th, 2026. +::: + + + +## Create a cluster + +The hub runs on a Kubernetes cluster. Create a local one with kind. + +Demo mode publishes the hub gateway on node port `30443`. kind runs your node as +a container, so map that port to `8443` on your machine when you create the +cluster. + +```shell +kind create cluster --name hub-quickstart --config - <<'EOF' +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +name: hub-quickstart +nodes: + - role: control-plane + extraPortMappings: + - containerPort: 30443 + hostPort: 8443 + protocol: TCP +EOF +``` + +## Install the hub + +Install the hub chart in demo mode. Demo mode bundles the supporting +infrastructure a production install expects you to bring yourself. + +```shell +helm upgrade --install hub oci://xpkg.upbound.io/upbound/hub \ + --namespace hub --create-namespace \ + --version 1.0.0 \ + --set global.demo.enabled=true \ + --wait +``` + +### What the chart installs + +Every hub install creates three components in the `hub` namespace: + +| Component | What it does | +| --- | --- | +| `hub-core` | The hub API, plus a migration job that prepares its database | +| `hub-connector` | Registers this cluster's control planes with the hub | +| `hub-webui` | The Console | + +`global.demo.enabled=true` adds the rest of the stack: + +| Component | What it does | In a production install | +| --- | --- | --- | +| Keycloak | Identity provider behind Console login | Point the hub at your own OIDC provider | +| PostgreSQL | Database for `hub-core` | Supply your own PostgreSQL instance | +| An endpoint secured with TLS | Publishes the hub at `hub.127.0.0.1.nip.io` with a self-signed certificate | Use your own hostname, load balancer, and certificate | +| Demo users and their RBAC bindings | Lets you sign in and compare permission levels | Map your own users and groups | + + +:::warning +The demo PostgreSQL writes to an `emptyDir`, so its pod losing its node loses +your hub data. Demo mode is for trying the hub out. Don't run it in production. +::: + + + + + +## Check out Insights in the Console + + + + +The port mapping from the `kind create cluster` step already publishes the +Console. Open your browser to the +[hub dashboard](https://hub.127.0.0.1.nip.io:8443/dashboard). + +The pages under **Explore** are [Insights][insights], the part of the hub that +aggregates resources from every connected control plane and serves them through +one API. The rest of this quickstart uses Insights to watch each control plane +and resource you add. + + +:::tip +This demo uses a self-signed certificate, so your browser warns you the site +isn't secure. In Firefox, select **Advanced** then **Accept the Risk and +Continue**. Chrome and Safari put the same option behind **Advanced**. Expect the +warning: the demo runs on your own machine with a certificate no authority +signed. A real hub install uses a certificate you supply. +::: + + +Sign in as `admin` with the password `admin`. + +![The Console home page with quick access tiles and getting started links](/img/quickstart/console-home.png) + +The [dashboard](https://hub.127.0.0.1.nip.io:8443/dashboard) counts the control +planes, resources, and definitions the hub sees: + +![The hub dashboard showing control plane, resource, and definition counts](/img/quickstart/dashboard.png) + +The [definitions page](https://hub.127.0.0.1.nip.io:8443/explore/definitions) +lists every API kind across the connected control planes. See +[Definitions][definitions] for what Insights correlates there: + +![The definitions page listing CRDs and their API groups](/img/quickstart/definitions.png) + +Demo mode also creates five other users, each with the password `password`, so +you can see how the Console changes with permission level: + +| Username | Group | +| --- | --- | +| `admin` | admin | +| `editor-alice`, `editor-bob` | editor | +| `viewer-charlie`, `viewer-diana`, `viewer-eve` | viewer | + +## Next steps + +- [Part two][parttwo] to connect a second control plane to the hub. +- [Insights][insights] for everything the **Explore** pages can do. +- [Builders workshop][workshop] for real cloud resources. +- Explore the [hub][hub] for the centralized management installation. + + + + + +If you're stopping here, follow [Clean up](/getstarted/quickstart/create-resources#clean-up) +to delete what you created. + +[up]: /manuals/cli/overview/ +[insights]: /hub/products/insights/overview +[definitions]: /hub/products/insights/definitions +[parttwo]: /getstarted/quickstart/connect-a-second-control-plane +[partthree]: /getstarted/quickstart/create-resources +[hub]: /hub/ +[workshop]: /getstarted/builders-workshop/project-foundations diff --git a/docs/getstarted/upgrade-to-upbound/_category_.json b/docs/getstarted/upgrade-to-upbound/_category_.json deleted file mode 100644 index e937463d6..000000000 --- a/docs/getstarted/upgrade-to-upbound/_category_.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "label": "Upgrade to Upbound", - "position": 10 -} diff --git a/docs/getstarted/platform-tutorial.md b/docs/guides/platform-tutorial.md similarity index 99% rename from docs/getstarted/platform-tutorial.md rename to docs/guides/platform-tutorial.md index 74d0957ce..6bd54b02f 100644 --- a/docs/getstarted/platform-tutorial.md +++ b/docs/guides/platform-tutorial.md @@ -1,5 +1,5 @@ --- -title: Build your first platform with Upbound Crossplane +title: Build your first platform with UXP description: Deploy a real app with a cloud database, observe drift detection, enforce policies, and change infrastructure live, all from a single control plane. weight: 5 validation: diff --git a/docs/guides/solutions/design.md b/docs/guides/solutions/design.md index 539f034e6..afdcb7b62 100644 --- a/docs/guides/solutions/design.md +++ b/docs/guides/solutions/design.md @@ -62,6 +62,5 @@ provide. Pick one or two offerings, or use any of the available [Configurations [configurations]: https://marketplace.upbound.io/configurations -[bootstrap]: bootstrap.md [platformParts]: /img/solutions/platform-on-upbound.png diff --git a/docs/guides/usecases/cloud-resources-qs.md b/docs/guides/usecases/cloud-resources-qs.md index f819715fa..cc52c4324 100644 --- a/docs/guides/usecases/cloud-resources-qs.md +++ b/docs/guides/usecases/cloud-resources-qs.md @@ -51,17 +51,17 @@ up project init --template="project-template-gcp-storage" --language="python" my ## Understand the project -The project defines a resource type called `XStorageBucket`, which implements an +The project defines a resource type called `StorageBucket`, which implements an opinionated storage bucket abstraction. The type is defined as a Crossplane Composite Resource Definition (XRD) in the file -`apis/xstoragebuckets/definition.yaml`. +`apis/storagebuckets/definition.yaml`. -A Crossplane Composition defines the function pipeline that will run whenever an -`XStorageBucket` is created or updated. The Composition for the `XStorageBucket` -type is in `apis/xstoragebuckets/composition.yaml` and contains two functions: a +A Crossplane Composition defines the function pipeline that will run whenever a +`StorageBucket` is created or updated. The Composition for the `StorageBucket` +type is in `apis/storagebuckets/composition.yaml` and contains two functions: a project-specific one that creates resources, and a generic one that detects when the created resources become ready. The function that creates resources is implemented in the file `functions/compose-bucket/main.py`. @@ -332,7 +332,7 @@ The `examples/` directory in the project contains example resource manifests that you can deploy to test your project. Deploy an example: ```shell -kubectl apply -f examples/xstoragebuckets/example.yaml +kubectl apply -f examples/storagebucket/example.yaml ``` Navigate to the "Composite Resources" tab in the Web UI and click "Relationship @@ -340,18 +340,18 @@ View" to explore the cloud resources Crossplane creates. ## Update the custom resource type -You can add more fields to the `XStorageBucket` type to customize its +You can add more fields to the `StorageBucket` type to customize its behavior. For example, you may want create a README file in every bucket with user-specified contents. -Open the example resource `examples/xstoragebuckets/example.yaml` in your IDE of +Open the example resource `examples/storagebucket/example.yaml` in your IDE of choice and add a new field: -```yaml title="examples/xstoragebuckets/example.yaml" +```yaml title="examples/storagebucket/example.yaml" apiVersion: platform.example.com/v1alpha1 -kind: XStorageBucket +kind: StorageBucket metadata: name: example spec: @@ -363,9 +363,9 @@ spec: ``` -```yaml title="examples/xstoragebuckets/example.yaml" +```yaml title="examples/storagebucket/example.yaml" apiVersion: platform.example.com/v1alpha1 -kind: XStorageBucket +kind: StorageBucket metadata: name: example spec: @@ -377,9 +377,9 @@ spec: ``` -```yaml title="examples/xstoragebuckets/example.yaml" +```yaml title="examples/storagebucket/example.yaml" apiVersion: platform.example.com/v1alpha1 -kind: XStorageBucket +kind: StorageBucket metadata: name: example spec: @@ -398,17 +398,17 @@ prompted, confirm the overwrite: ```shell -up xrd generate examples/xstoragebuckets/example.yaml --path xstoragebucket/definition.yaml +up xrd generate examples/storagebucket/example.yaml --path apis/storagebuckets/definition.yaml ``` ```shell -up xrd generate examples/xstoragebuckets/example.yaml +up xrd generate examples/storagebucket/example.yaml ``` ```shell -up xrd generate examples/xstoragebuckets/example.yaml +up xrd generate examples/storagebucket/example.yaml ``` @@ -424,7 +424,7 @@ from crossplane.function import resource from crossplane.function.proto.v1 import run_function_pb2 as fnv1 from .model.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1 -from .model.com.example.platform.xstoragebucket import v1alpha1 +from .model.com.example.platform.storagebucket import v1alpha1 from .model.io.upbound.aws.s3.bucket import v1beta1 as bucketv1beta1 from .model.io.upbound.aws.s3.bucketacl import v1beta1 as aclv1beta1 from .model.io.upbound.aws.s3.bucketownershipcontrols import v1beta1 as bocv1beta1 @@ -437,7 +437,7 @@ from .model.io.upbound.aws.s3.bucketserversideencryptionconfiguration import ( def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): - observed_xr = v1alpha1.XStorageBucket(**req.observed.composite.resource) + observed_xr = v1alpha1.StorageBucket(**req.observed.composite.resource) params = observed_xr.spec.parameters desired_bucket = bucketv1beta1.Bucket( @@ -573,11 +573,11 @@ from .model.io.upbound.azure.resourcegroup import v1beta1 as rgv1beta1 from .model.io.upbound.azure.storage.account import v1beta1 as acctv1beta1 from .model.io.upbound.azure.storage.container import v1beta1 as contv1beta1 from .model.io.upbound.azure.storage.blob import v1beta1 as blobv1beta1 -from .model.com.example.platform.xstoragebucket import v1alpha1 +from .model.com.example.platform.storagebucket import v1alpha1 def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): - observed_xr = v1alpha1.XStorageBucket(**req.observed.composite.resource) + observed_xr = v1alpha1.StorageBucket(**req.observed.composite.resource) params = observed_xr.spec.parameters # Create the resource group @@ -671,11 +671,11 @@ from crossplane.function.proto.v1 import run_function_pb2 as fnv1 from .model.io.upbound.gcp.storage.bucket import v1beta1 as bucketv1beta1 from .model.io.upbound.gcp.storage.bucketacl import v1beta1 as aclv1beta1 from .model.io.upbound.gcp.storage.bucketobject import v1beta1 as objv1beta1 -from .model.com.example.platform.xstoragebucket import v1alpha1 +from .model.com.example.platform.storagebucket import v1alpha1 def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): - observed_xr = v1alpha1.XStorageBucket(**req.observed.composite.resource) + observed_xr = v1alpha1.StorageBucket(**req.observed.composite.resource) params = observed_xr.spec.parameters desired_bucket = bucketv1beta1.Bucket( @@ -746,7 +746,7 @@ up project run --local If you haven't already deployed an example resource, do so now: ```shell -kubectl apply -f examples/xstoragebuckets/example.yaml +kubectl apply -f examples/storagebucket/example.yaml ``` Use the Web UI to observe that Crossplane created the additional readme @@ -758,10 +758,10 @@ kubectl get managed ## Clean up -To avoid leaving cloud resources behind, delete your `XStorageBucket`: +To avoid leaving cloud resources behind, delete your `StorageBucket`: ```shell -kubectl delete -f examples/xstoragebuckets/example.yaml +kubectl delete -f examples/storagebucket/example.yaml ``` @@ -775,7 +775,7 @@ up project stop ## Do more with your control plane -The example above creates storage bucket resources whenever an `XStorageBucket` +The example above creates storage bucket resources whenever a `StorageBucket` gets created. But what if you don't want to create buckets? The Upbound Marketplace is the hub for finding additional packages to extend your control plane, such as Providers, or pre-built Functions. diff --git a/docs/guides/usecases/managed-resources.md b/docs/guides/usecases/managed-resources.md index bcd57f756..ff2a5e2c7 100644 --- a/docs/guides/usecases/managed-resources.md +++ b/docs/guides/usecases/managed-resources.md @@ -198,6 +198,6 @@ external services: [functions]: /manuals/uxp/concepts/composition/composite-resource-definitions [operations]: /manuals/uxp/concepts/operations/overview [providers]: /manuals/uxp/concepts/packages/providers -[composition]: /getstarted/introduction/project +[composition]: /getstarted/builders-workshop/project-foundations diff --git a/docs/manuals/cli/howtos/compositions/go-template.md b/docs/manuals/cli/howtos/compositions/go-template.md index a9af223c1..e8509a19b 100644 --- a/docs/manuals/cli/howtos/compositions/go-template.md +++ b/docs/manuals/cli/howtos/compositions/go-template.md @@ -300,11 +300,18 @@ To update the composite resource's status, have your templates output a resource --- apiVersion: devexdemo.upbound.io/v1alpha1 -kind: XBucket +kind: Bucket status: someInformation: cool-status ``` +:::tip +The `kind` here must match your XRD's `kind` exactly. This example assumes a +`v2` XRD (`apiextensions.crossplane.io/v2`) with `kind: Bucket`. If your XRD +uses the legacy `v1` API (`apiextensions.crossplane.io/v1`), your XRD's `kind` +uses an `X` prefix, for example `kind: XBucket`, so use that `kind` instead. +::: + To set conditions on the claim and composite, you can add a `ClaimConditions` resource to your templates: ```yaml diff --git a/docs/manuals/cli/howtos/compositions/go.md b/docs/manuals/cli/howtos/compositions/go.md index cdea57ebd..2a3ed1b64 100644 --- a/docs/manuals/cli/howtos/compositions/go.md +++ b/docs/manuals/cli/howtos/compositions/go.md @@ -100,6 +100,14 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) The following example function composes an S3 bucket based on a simplified bucket XRD: +:::tip +The generated model type name matches your XRD's `kind` exactly. This example +assumes a `v2` XRD (`apiextensions.crossplane.io/v2`) with `kind: StorageBucket`. +If your XRD uses the legacy `v1` API (`apiextensions.crossplane.io/v1`), your +XRD's `kind` uses an `X` prefix, for example `kind: XStorageBucket`, so import +`v1alpha1.XStorageBucket` instead. +::: + ```go package main @@ -138,7 +146,7 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) return rsp, nil } - var xr v1alpha1.XStorageBucket + var xr v1alpha1.StorageBucket if err := convertViaJSON(&xr, observedComposite.Resource); err != nil { response.Fatal(rsp, errors.Wrap(err, "cannot convert xr")) return rsp, nil @@ -209,8 +217,8 @@ import ( "dev.upbound.io/models/com/example/platform/v1alpha1" "dev.upbound.io/models/io/upbound/aws/s3/v1beta1" - "github.com/crossplane/crossplane-runtime/pkg/logging" "github.com/crossplane/function-sdk-go/errors" + "github.com/crossplane/function-sdk-go/logging" fnv1 "github.com/crossplane/function-sdk-go/proto/v1" "github.com/crossplane/function-sdk-go/request" "github.com/crossplane/function-sdk-go/resource" @@ -243,7 +251,7 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) return rsp, nil } - var xr v1alpha1.XStorageBucket + var xr v1alpha1.StorageBucket if err := convertViaJSON(&xr, observedComposite.Resource); err != nil { response.Fatal(rsp, errors.Wrap(err, "cannot convert xr")) return rsp, nil @@ -520,7 +528,7 @@ if err != nil { return rsp, nil } -var xr v1alpha1.XStorageBucket +var xr v1alpha1.StorageBucket if err := convertViaJSON(&xr, observedComposite.Resource); err != nil { response.Fatal(rsp, errors.Wrap(err, "cannot convert xr")) return rsp, nil diff --git a/docs/manuals/cli/howtos/compositions/python.md b/docs/manuals/cli/howtos/compositions/python.md index 339bf76d7..0b7408ad4 100644 --- a/docs/manuals/cli/howtos/compositions/python.md +++ b/docs/manuals/cli/howtos/compositions/python.md @@ -120,10 +120,19 @@ Add the model to your function: ```python from crossplane.function.proto.v1 import run_function_pb2 as fnv1 -from .model.com.example.platform.xstoragebucket import v1alpha1 +from .model.com.example.platform.storagebucket import v1alpha1 from .model.io.upbound.aws.s3.bucket import v1beta1 as bucketv1beta1 ``` +:::tip +The generated model module and class name match your XRD's `kind` exactly. The +examples on this page assume a `v2` XRD (`apiextensions.crossplane.io/v2`) with +`kind: StorageBucket`. If your XRD uses the legacy `v1` API +(`apiextensions.crossplane.io/v1`), your XRD's `kind` uses an `X` prefix, for +example `kind: XStorageBucket`, so import from `.model...xstoragebucket` and +use `v1alpha1.XStorageBucket` instead. +::: + The imports in this example are specifically for AWS S3 buckets. They follow a similar structure for all resources: @@ -153,7 +162,7 @@ using the resource's status model directly. from crossplane.function import resource from crossplane.function.proto.v1 import run_function_pb2 as fnv1 -from .model.com.example.platform.xstoragebucket import v1alpha1 +from .model.com.example.platform.storagebucket import v1alpha1 def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): @@ -185,11 +194,11 @@ from crossplane.function import resource from crossplane.function.proto.v1 import run_function_pb2 as fnv1 from .model.io.upbound.aws.s3.bucket import v1beta1 as bucketv1beta1 -from .model.org.example.xstoragebucket import v1alpha1 +from .model.org.example.storagebucket import v1alpha1 def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): - observed_xr = v1alpha1.XStorageBucket(**req.observed.composite.resource) + observed_xr = v1alpha1.StorageBucket(**req.observed.composite.resource) desired_bucket = bucketv1beta1.Bucket( spec=bucketv1beta1.Spec( @@ -211,11 +220,11 @@ from crossplane.function import resource from crossplane.function.proto.v1 import run_function_pb2 as fnv1 from .model.io.upbound.aws.s3.bucket import v1beta1 as bucketv1beta1 -from .model.org.example.xstoragebucket import v1alpha1 +from .model.org.example.storagebucket import v1alpha1 def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): - observed_xr = v1alpha1.XStorageBucket(**req.observed.composite.resource) + observed_xr = v1alpha1.StorageBucket(**req.observed.composite.resource) region = "us-west-2" if observed_xr.spec.region is not None: @@ -239,11 +248,11 @@ from crossplane.function.proto.v1 import run_function_pb2 as fnv1 from .model.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1 from .model.io.upbound.aws.s3.bucket import v1beta1 as bucketv1beta1 -from .model.org.example.xstoragebucket import v1alpha1 +from .model.org.example.storagebucket import v1alpha1 def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): - observed_xr = v1alpha1.XStorageBucket(**req.observed.composite.resource) + observed_xr = v1alpha1.StorageBucket(**req.observed.composite.resource) desired_bucket = bucketv1beta1.Bucket( from .model.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1 @@ -268,13 +277,13 @@ Next, add the function logic. The example below creates an S3 bucket: from crossplane.function import resource from crossplane.function.proto.v1 import run_function_pb2 as fnv1 -from .model.com.example.platform.xstoragebucket import v1alpha1 +from .model.com.example.platform.storagebucket import v1alpha1 from .model.io.upbound.aws.s3.bucket import v1beta1 as bucketv1beta1 def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): # Load the observed XR into a Pydantic model. - observed_xr = v1alpha1.XStorageBucket(**req.observed.composite.resource) + observed_xr = v1alpha1.StorageBucket(**req.observed.composite.resource) # Create the cloud resource specification desired_bucket = bucketv1beta1.Bucket( @@ -297,7 +306,7 @@ In the `RunFunctionRequest`, there are four _inputs_ that Crossplane can parse: ```python # The API request - observed_xr = v1alpha1.XStorageBucket(**req.observed.composite.resource) + observed_xr = v1alpha1.StorageBucket(**req.observed.composite.resource) ``` 2. **Desired state**: What resources should exist? @@ -350,7 +359,7 @@ from crossplane.function import resource from crossplane.function.proto.v1 import run_function_pb2 as fnv1 from .model.io.k8s.apimachinery.pkg.apis.meta import v1 as metav1 -from .model.com.example.platform.xstoragebucket import v1alpha1 +from .model.com.example.platform.storagebucket import v1alpha1 from .model.io.upbound.aws.s3.bucket import v1beta1 as bucketv1beta1 from .model.io.upbound.aws.s3.bucketacl import v1beta1 as aclv1beta1 from .model.io.upbound.aws.s3.bucketownershipcontrols import v1beta1 as bocv1beta1 @@ -362,7 +371,7 @@ from .model.io.upbound.aws.s3.bucketserversideencryptionconfiguration import ( def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): - observed_xr = v1alpha1.XStorageBucket(**req.observed.composite.resource) + observed_xr = v1alpha1.StorageBucket(**req.observed.composite.resource) params = observed_xr.spec.parameters desired_bucket = bucketv1beta1.Bucket( @@ -517,7 +526,7 @@ the response: from crossplane.function import resource from crossplane.function.proto.v1 import run_function_pb2 as fnv1 -from .model.com.example.platform.xmytype import v1alpha1 +from .model.com.example.platform.mytype import v1alpha1 def compose(req: fnv1.RunFunctionRequest, rsp: fnv1.RunFunctionResponse): diff --git a/docs/manuals/cli/howtos/project.md b/docs/manuals/cli/howtos/project.md index c1d765215..b8660fb18 100644 --- a/docs/manuals/cli/howtos/project.md +++ b/docs/manuals/cli/howtos/project.md @@ -156,7 +156,7 @@ Next, you need to add the necessary dependencies to enable Upbound communication with your cloud providers and services. Proceed to the [add dependencies to your project][add-dependencies-to-your-project] guide. -[quickstart]: /getstarted/introduction/project +[quickstart]: /getstarted/builders-workshop/project-foundations [workshop]: /getstarted/builders-workshop/project-foundations [up-project-init]: /reference/cli-reference [cli-reference-documentation]: /reference/cli-reference diff --git a/docs/manuals/cli/howtos/testing.md b/docs/manuals/cli/howtos/testing.md index c096a1dd5..7152a165a 100644 --- a/docs/manuals/cli/howtos/testing.md +++ b/docs/manuals/cli/howtos/testing.md @@ -90,7 +90,7 @@ You can write tests in KCL, Python, or Go. For example, to generate a composition test: - + ```shell {copy-lines="all"} up test generate my-test --language=go @@ -123,7 +123,7 @@ exercise the Crossplane composition controller, which handles reconciliation and external resource management. - + ```go @@ -286,7 +286,7 @@ You can write tests in KCL, Python, or Go. For example, to generate an end-to-end test: - + ```shell {copy-lines="all"} up test generate my-e2e-test --e2e --language=go @@ -314,7 +314,7 @@ up test generate my-e2e-test --e2e --language=kcl End-to-end tests use the `E2ETest` API, written in KCL, Python, or Go. - + diff --git a/docs/manuals/cli/overview.md b/docs/manuals/cli/overview.md index 2ddc434c3..90652777c 100644 --- a/docs/manuals/cli/overview.md +++ b/docs/manuals/cli/overview.md @@ -38,7 +38,7 @@ For example, to install version {versions.cli} use the following command: {`curl -sL "https://cli.upbound.io" | VERSION=v${versions.cli} sh`} -Find the full list of versions in the Up command-line repository. +Find the full list of versions in the Up command-line repository. ::: diff --git a/docs/manuals/marketplace/repositories/overview.md b/docs/manuals/marketplace/repositories/overview.md index 8fcb0374d..a987e97f9 100644 --- a/docs/manuals/marketplace/repositories/overview.md +++ b/docs/manuals/marketplace/repositories/overview.md @@ -26,7 +26,7 @@ Upbound repositories support these Crossplane package types: `Configurations`, ` * **`Provider`** packages combine a [Kubernetes controller][kubernetes-controller] container, associated _Custom Resource Definitions_ (`CRDs`) and metadata. The Crossplane open source [AWS provider package][aws-provider-package] is an example a provider's metadata and `CRDs`. -[up-cli]: / +[up-cli]: /manuals/cli/overview [upbound-marketplace]: /manuals/marketplace/overview [store-configurations-in-a-repostiory]: /manuals/marketplace/repositories/store-configurations [kubernetes-controller]: https://kubernetes.io/docs/concepts/architecture/controller/ diff --git a/docs/manuals/platform/k8s-rbac.md b/docs/manuals/platform/k8s-rbac.md index 9eb111ff9..3cbcf902b 100644 --- a/docs/manuals/platform/k8s-rbac.md +++ b/docs/manuals/platform/k8s-rbac.md @@ -1,7 +1,7 @@ --- title: Authorize actions in control planes sidebar_position: 8 -description: A guide to implementing and configuring Kuberentes RBAC in Upbound +description: A guide to implementing and configuring Kubernetes RBAC in Upbound --- :::note diff --git a/docs/manuals/uxp/concepts/managed-resources/overview.md b/docs/manuals/uxp/concepts/managed-resources/overview.md index 358efffe9..f3dc91ac5 100644 --- a/docs/manuals/uxp/concepts/managed-resources/overview.md +++ b/docs/manuals/uxp/concepts/managed-resources/overview.md @@ -859,12 +859,12 @@ Conditions: [policies]: /manuals/uxp/concepts/managed-resources/overview#managementpolicies [providerconfig]: /manuals/uxp/concepts/packages/providers#provider-configuration -[official-provider-aws]: https://marketplace.upbound.io/providers/provider-family-aws -[official-provider-azure]: https://marketplace.upbound.io/providers/provider-family-azure -[official-provider-gcp]: https://marketplace.upbound.io/providers/provider-family-gcp -[aws]: https://marketplace.upbound.io/providers/provider-family-aws -[gcp]: https://marketplace.upbound.io/providers/provider-family-gcp -[azure]: https://marketplace.upbound.io/providers/provider-family-azure +[official-provider-aws]: https://marketplace.upbound.io/providers/upbound/provider-family-aws +[official-provider-azure]: https://marketplace.upbound.io/providers/upbound/provider-family-azure +[official-provider-gcp]: https://marketplace.upbound.io/providers/upbound/provider-family-gcp +[aws]: https://marketplace.upbound.io/providers/upbound/provider-family-aws +[gcp]: https://marketplace.upbound.io/providers/upbound/provider-family-gcp +[azure]: https://marketplace.upbound.io/providers/upbound/provider-family-azure [xrs]: /manuals/uxp/concepts/composition/composite-resources [crossplane-discussion]: https://github.com/crossplane/crossplane/issues/4839 [k8s-finalizers]: https://kubernetes.io/docs/concepts/overview/working-with-objects/finalizers/ diff --git a/docs/getstarted/upgrade-to-upbound/migrate-configurations-v2.md b/docs/manuals/uxp/howtos/migrate-configurations-v2.md similarity index 99% rename from docs/getstarted/upgrade-to-upbound/migrate-configurations-v2.md rename to docs/manuals/uxp/howtos/migrate-configurations-v2.md index 15033873d..f0ef56cbc 100644 --- a/docs/getstarted/upgrade-to-upbound/migrate-configurations-v2.md +++ b/docs/manuals/uxp/howtos/migrate-configurations-v2.md @@ -795,5 +795,5 @@ cleaner, more intuitive APIs. [xrd-scope]: https://docs.crossplane.io/latest/composition/composite-resource-definitions/#xrd-scope [connection-details]: https://docs.crossplane.io/latest/guides/connection-details-composition -[upgrade-uxp]: /getstarted/upgrade-to-upbound/upgrade-to-uxp +[upgrade-uxp]: /manuals/uxp/howtos/upgrade-to-uxp [provider-migration]: /manuals/packages/providers/migration diff --git a/docs/getstarted/upgrade-to-upbound/upgrade-to-uxp.md b/docs/manuals/uxp/howtos/upgrade-to-uxp.md similarity index 88% rename from docs/getstarted/upgrade-to-upbound/upgrade-to-uxp.md rename to docs/manuals/uxp/howtos/upgrade-to-uxp.md index 0be81259c..fff15f309 100644 --- a/docs/getstarted/upgrade-to-upbound/upgrade-to-uxp.md +++ b/docs/manuals/uxp/howtos/upgrade-to-uxp.md @@ -213,32 +213,32 @@ path uses a **free Community license**. 2. Choose your upgrade method and run the upgrade: - - + + - Add the Upbound repository and upgrade your Crossplane cluster: - ```shell - helm repo add upbound-stable https://charts.upbound.io/stable && helm repo update - helm upgrade --install crossplane --namespace crossplane-system upbound-stable/crossplane --version "${UXP_VERSION}" - ``` - +Add the Upbound repository and upgrade your Crossplane cluster: +```shell +helm repo add upbound-stable https://charts.upbound.io/stable && helm repo update +helm upgrade --install crossplane --namespace crossplane-system upbound-stable/crossplane --version "${UXP_VERSION}" +``` + - + - First, download the CLI: +First, download the CLI: - ```shell - curl -sL "https://cli.upbound.io" | sh - ``` +```shell +curl -sL "https://cli.upbound.io" | sh +``` - Next, upgrade your Crossplane cluster to UXP: +Next, upgrade your Crossplane cluster to UXP: - ```shell - up uxp upgrade "${UXP_VERSION}" - ``` +```shell +up uxp upgrade "${UXP_VERSION}" +``` - - + + ## Upgrade to Commercial UXP @@ -270,32 +270,32 @@ for more information. 2. Choose your upgrade method and run the upgrade: - - + + - Add the Upbound repository and upgrade your Crossplane cluster: - ```shell - helm repo add upbound-stable https://charts.upbound.io/stable && helm repo update - helm upgrade --install crossplane --namespace crossplane-system upbound-stable/crossplane --version "${UXP_VERSION}" - ``` - +Add the Upbound repository and upgrade your Crossplane cluster: +```shell +helm repo add upbound-stable https://charts.upbound.io/stable && helm repo update +helm upgrade --install crossplane --namespace crossplane-system upbound-stable/crossplane --version "${UXP_VERSION}" +``` + - + - First, download the CLI: +First, download the CLI: - ```shell - curl -sL "https://cli.upbound.io" | sh - ``` +```shell +curl -sL "https://cli.upbound.io" | sh +``` - Next, upgrade your Crossplane cluster to UXP: +Next, upgrade your Crossplane cluster to UXP: - ```shell - up uxp upgrade "${UXP_VERSION}" - ``` +```shell +up uxp upgrade "${UXP_VERSION}" +``` - - + + 3. Install your Commercial License: diff --git a/docs/manuals/uxp/howtos/uxp-deployment.md b/docs/manuals/uxp/howtos/uxp-deployment.md index 10600cd65..9277c1eb7 100644 --- a/docs/manuals/uxp/howtos/uxp-deployment.md +++ b/docs/manuals/uxp/howtos/uxp-deployment.md @@ -96,5 +96,5 @@ For more information on upgrading to UXP, review the [upgrade guide][migration] Read [license management][license-management] to learn how to add a license to unlock commercial features in Upbound Crossplane. [spaces]: /self-hosted-spaces/overview -[migration]: /getstarted/upgrade-to-upbound/upgrade-to-uxp/ +[migration]: /manuals/uxp/howtos/upgrade-to-uxp [license-management]: /manuals/uxp/howtos/license-management diff --git a/docs/manuals/uxp/overview.md b/docs/manuals/uxp/overview.md index 1a50393a2..75ca33455 100644 --- a/docs/manuals/uxp/overview.md +++ b/docs/manuals/uxp/overview.md @@ -52,7 +52,7 @@ Upbound runs UXP for you in our control planes-as-a-service Spaces hosting envir Read the [Get Started][get-started] guide to learn how to use UXP to build your own control plane. [crossplane]: https://docs.crossplane.io -[control-plane]: /getstarted/#what-is-upbound +[control-plane]: /#what-is-upbound [self-managed-uxp]: /manuals/uxp/howtos/uxp-deployment [licensing]: /manuals/uxp/howtos/license-management [intelligent-control-planes]: /manuals/uxp/concepts/intelligent-control-planes @@ -60,7 +60,7 @@ Read the [Get Started][get-started] guide to learn how to use UXP to build your [concepts]: /manuals/uxp/concepts/composition/overview [features]: /manuals/uxp/concepts/intelligent-control-planes [guides]: /manuals/uxp/howtos/uxp-deployment -[get-started]: /getstarted +[get-started]: / [backup-restore]: /manuals/uxp/howtos/backup-and-restore [function-scale-to-zero]: /manuals/uxp/howtos/function-scale-to-zero [official-package-support]: /manuals/uxp/official-package-support diff --git a/docs/manuals/uxp/upgrade-from-crossplane.md b/docs/manuals/uxp/upgrade-from-crossplane.md new file mode 100644 index 000000000..c2744935c --- /dev/null +++ b/docs/manuals/uxp/upgrade-from-crossplane.md @@ -0,0 +1,182 @@ +--- +title: Upgrade from Crossplane +sidebar_position: 3 +pagination_prev: null +pagination_next: null +--- + + +Already running Crossplane? In this guide, you'll rehearse an upgrade on a +throwaway cluster: stand up Crossplane with one resource, export its state, +import it into a new Upbound control plane, activate it, and watch it reconcile +in the Upbound hub. + + +## Why upgrade to Upbound + +Upbound Crossplane (UXP) adds operational features useful for Crossplane +operators. UXP gives you a secrets proxy, backup and restore, and control plane insights. +This tutorial's upgrade path allows you to keep your existing compositions and managed +resources. + + +## Prerequisites: + +Before you begin, make sure you have: + +* `kind` (for the disposable "before" cluster) +* `kubectl` +* `helm` +* [up CLI][upCli] +* An Upbound account + +## Step 1: Log in to Upbound + +```shell +up login +``` + +## Step 2: Stand up a throwaway Crossplane control plane + +The setup script creates a kind cluster, installs Crossplane and the +`provider-nop` provider, creates a single composite resource so +there's something real to migrate. + +
+ + Crossplane setup script + ```shell title="setup-crossplane.sh" manifest="/manifests/getstarted/migration/setup-crossplane.sh" + ``` +
+ +Download and run it: + +```shell +curl -fsSL "https://docs.upbound.io/manifests/getstarted/migration/setup-crossplane.sh" -o setup-crossplane.sh +bash setup-crossplane.sh +``` + +Confirm the composite resource reaches a ready state before you continue: + +```shell +kubectl get xapp sample-app +``` + +## Step 3: Export your control plane's state + +Export the source cluster's Crossplane state into a single archive. + +:::warning +Point `--kubeconfig` at the throwaway cluster, not whatever context happens to +be active. Exporting the wrong cluster is the first place this goes sideways. +::: + +```shell +up controlplane migration export \ + --kubeconfig ~/.kube/config \ + --output crossplane-export.tar.gz +``` + +The command reports the types it found, the resources it exported, and the +archive it wrote: + +```shell +Exporting control plane state... + Found 4 resource types + Exported 6 resources +Wrote archive to crossplane-export.tar.gz +``` + +## Step 4: Create your Upbound control plane + +Create the destination control plane and switch your context to it. + +```shell +up controlplane create +up ctx "///" +``` + +:::warning +`up ctx` points your active context to the new control plane. Confirm you're +targeting the destination before continuing, so the import lands where you +expect. +::: + +## Step 5: Import the archive + +```shell +up controlplane migration import --input crossplane-export.tar.gz +``` + +Imported resources land **paused** by default, so nothing reconciles yet. This +is deliberate: it gives you a chance to review before anything acts on external +infrastructure. + +```shell +Importing control plane state... + Imported 6 resources (paused) +Import complete +``` + +## Step 6: Review before activating + +Spot-check that the imported resources and claims look right: + +```shell +kubectl get managed +kubectl get composite +``` + +:::warning +This step is the highest-stakes moment in the migration. Before you unpause, +confirm you're pointed at the **new** control plane, not the source cluster. +Activating on the wrong cluster leaves two control planes reconciling the same +external resources at once. +::: + + +## Step 7: Activate + +Remove the paused annotation to let the new control plane take over +reconciliation: + +```shell +kubectl annotate managed --all crossplane.io/paused- +``` + +The resources move to a synced and ready state as the new control plane +reconciles them: + +```shell +kubectl get managed +``` + +## Step 8: See it in the Console + +Open the [Upbound Console][console], select your new control plane, and find the +migrated `sample-app` resource. + +## Clean up + +When you're done with this quickstart, tear down the resources created: + +```shell +kind delete cluster --name crossplane-source +``` + +:::note +Your real source cluster isn't disposable the way this one is. When you migrate +production, decommission the original cluster only after you've confirmed the +new control plane is healthy and reconciling. +::: + +## Next steps + +- [Migrate a production control plane][migrate] when you're ready for the real + thing. +- [Hub overview][hub] for the full-fleet story. + +[upCli]: /manuals/cli/overview +[console]: https://console.upbound.io +[hub]: /hub/ +[migrate]: /manuals/uxp/howtos/upgrade-to-uxp diff --git a/docs/getstarted/upgrade-to-upbound/upgrade-to-projects.md b/docs/manuals/uxp/upgrade-to-projects.md similarity index 99% rename from docs/getstarted/upgrade-to-upbound/upgrade-to-projects.md rename to docs/manuals/uxp/upgrade-to-projects.md index d55607184..6c678da74 100644 --- a/docs/getstarted/upgrade-to-upbound/upgrade-to-projects.md +++ b/docs/manuals/uxp/upgrade-to-projects.md @@ -1,7 +1,7 @@ --- title: Upgrade to Control Plane Projects description: Adopt control plane projects -sidebar_position: 1 +sidebar_position: 3 --- If you're already running Crossplane and want to use Upbound Crossplane's diff --git a/docs/reference/apis/project-api/index.md b/docs/reference/apis/project-api/index.md index f1681f39d..80caa3ce6 100644 --- a/docs/reference/apis/project-api/index.md +++ b/docs/reference/apis/project-api/index.md @@ -211,6 +211,6 @@ When a project is loaded, the following defaults are applied if not specified: [configuration-overview]: /reference/apis/crossplane-api/ -[upgrade-to-projects]: /getstarted/upgrade-to-upbound/upgrade-to-projects/ +[upgrade-to-projects]: /manuals/uxp/upgrade-to-projects [builders-workshop]: /getstarted/builders-workshop/project-foundations/ [cli-reference]: /reference/cli-reference/ diff --git a/docusaurus.config.js b/docusaurus.config.js index b03a8f966..65996cb87 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -72,12 +72,6 @@ const config = { ], ], plugins: [ - [ - "docusaurus-pushfeedback", - { - project: "0p5hvygqxb", - }, - ], [ "@docusaurus/plugin-content-docs", { @@ -85,6 +79,7 @@ const config = { path: "docs", routeBasePath: "/", sidebarPath: require.resolve("./src/sidebars/main.js"), + remarkPlugins: [require("./scripts/manifest-embed-plugin")], }, ], [ @@ -111,6 +106,23 @@ const config = { routeBasePath: "/cloud-spaces", sidebarPath: require.resolve("./src/sidebars/cloud-spaces.js"), }, + ], + [ + "@docusaurus/plugin-content-docs", + { + id: "hub", + path: "hub-docs", + routeBasePath: "/hub", + sidebarPath: require.resolve("./src/sidebars/hub.js"), + remarkPlugins: [require("./scripts/manifest-embed-plugin")], + includeCurrentVersion: true, + lastVersion: "current", + versions: { + current: { + label: "1.0", + }, + }, + }, ], "./scripts/plan-plugin.js", function (context, options) { @@ -184,7 +196,7 @@ const config = { { label: "Get Started", position: "left", - to: "/getstarted/", + to: "/", }, { type: "dropdown", @@ -225,13 +237,21 @@ const config = { to: "/self-hosted-spaces/overview/", }, { - label: "CLI", - to: "/manuals/cli/overview/", + label: "Hub", + to: "/hub/", }, { label: "Console", to: "/manuals/console/upbound-console/", }, + { + label: "Platform", + to: "/manuals/platform/overview/", + }, + { + label: "CLI", + to: "/manuals/cli/overview/", + }, { label: "Packages", to: "/manuals/packages/overview/", @@ -240,10 +260,6 @@ const config = { label: "Marketplace", to: "/manuals/marketplace/overview/", }, - { - label: "Platform", - to: "/manuals/platform/overview/", - }, ], }, { @@ -295,7 +311,7 @@ const config = { prism: { theme: prismThemes.github, darkTheme: prismThemes.dracula, - additionalLanguages: ["bash", "yaml", "json", "go", "python"], + additionalLanguages: ["bash", "yaml", "json", "go", "python", "http"], }, colorMode: { defaultMode: "light", diff --git a/hub-docs/concepts/_category_.json b/hub-docs/concepts/_category_.json new file mode 100644 index 000000000..2808493a8 --- /dev/null +++ b/hub-docs/concepts/_category_.json @@ -0,0 +1,5 @@ +{ + "label": "Concepts", + "position": 2, + "collapsed": true +} diff --git a/hub-docs/concepts/architecture.md b/hub-docs/concepts/architecture.md new file mode 100644 index 000000000..99dcabb8c --- /dev/null +++ b/hub-docs/concepts/architecture.md @@ -0,0 +1,120 @@ +--- +title: Architecture +sidebar_position: 1 +description: How the pieces of Hub fit together. +--- + +## Architecture + +Hub refers generally to a set of Upbound products, spread throughout services +running on single cluster, with one or more Kubernetes clusters sending data +back through an instance of `hub-connector`. The microservices within Hub are +stateless applications storing data in external databases The `hub-core` +provides the central authority for authentication, ingest and APIs. The +`hub-webui` is an optional +web interface for interacting with the Hub. + +```mermaid +flowchart TB + subgraph hub["Hub installation cluster"] + ing["Ingress / Gateway API
(your TLS cert)"] + webui["hub-webui"] + api["hub-core"] + ing --> webui + webui <--> api + ing <--> api + end + + browser["Browser / CLI clients"] + ing -->|HTTPS| browser + + oidc["OIDC provider (external)"] + pg["PostgreSQL (external)"] + api --> pg + api -->|OIDC discovery| oidc + + subgraph obsA["Observed cluster A"] + connA["hub-connector"] + resA["Crossplane resources"] + connA --> resA + end + + subgraph obsB["Observed cluster B"] + connB["hub-connector"] + resB["Crossplane resources"] + connB --> resB + end + + connA -->|HTTPS| ing + connB -->|HTTPS| ing +``` + +## What you provide + +- **A PostgreSQL database** The Hub requires its own database. Upbound + officially supports only managed PostgreSQL offerings (RDS, Cloud SQL, Azure + Database for PostgreSQL). See [the databases overview][overview] for the + version, extensions, and authentication modes Hub supports. +- **An OIDC provider** Any OIDC-compliant provider with a discovery endpoint, + email claim, and configurable group claim works. See [the OIDC + overview][oidc-configuration] for the contract and the per-provider guides. +- **Ingress** Provide a Gateway API setup or an + Ingress controller, plus a CA-signed certificate attached to hostnames that + will expose your API and (optionally) the UI. + +The full pre-flight checklist (versions, sizing, network paths) lives in +[prerequisites][prerequisites]. Read it before you move to the install page. + +## What Upbound supports + +Upbound tests and supports Hub when its database and supporting infrastructure +run on AWS, GCP, or Azure. +These environments are the only ones covered by Upbound support. Running Hub on any +other cloud provider, on-premises, or in a self-hosted environment is possible +but unsupported. In these configurations you are responsible for provisioning, +operating, and troubleshooting the underlying infrastructure. Upbound may +provide best-effort guidance but doesn't guarantee compatibility, performance, +or issue resolution. Any such deployment is at your own risk. + +## What the chart provides + +The `hub` umbrella chart installs three subcharts - `hub-core`, +`hub-webui` and `hub-connector`. `hub-core` is the only chart that's required +for an operational API that can accept resources and serve responses. +`hub-webui` is optional (but recommended) to interact with the system without +using the API directly. + +- **`hub-core` Deployment.** The entrypoint API server for ingesting resources + and accessing the Upbound Platform products. +- **`hub-webui` Deployment.** The browser UI for all of the Upbound Platform + products. Optional, disable it if you only want the API. +- **`hub-connector` Deployment.** The component responsible for syncing state + between connected Kubernetes control planes and `hub-core`. The architecture + reference describes the connector's data and authentication flow. +- **Bootstrap configuration.** A Kubernetes Secret rendered from + `hub-core.bootstrap.files`. Use it to register your initial Hub objects at + install time. That covers the first `IdentityProvider`, the first + `ControlPlane`, plus an `OrganizationRoleBinding` linking your OIDC admin + group to Hub's admin role. + +## Next step + +Work through the section in order: + +- [Prerequisites][prerequisites]. The full pre-flight checklist for cluster, + network, storage, and dependencies. +- [OIDC configuration][oidc-configuration]. Provider contract and per-provider setup. +- [Databases overview][overview]. Postgres requirements and the AWS + RDS guide. +- [Install][install]. `helm install` against externally managed Postgres and + OIDC. + +Once Hub is serving real traffic, the [production +overview][production-overview] covers sizing, high availability, +autoscaling, RBAC, and upgrades. + +[install]: /hub/howtos/install +[oidc-configuration]: /hub/howtos/oidc-configuration +[overview]: /hub/howtos/databases/overview +[prerequisites]: /hub/howtos/prerequisites +[production-overview]: /hub/howtos/production-overview diff --git a/hub-docs/howtos/_category_.json b/hub-docs/howtos/_category_.json new file mode 100644 index 000000000..64f7d2582 --- /dev/null +++ b/hub-docs/howtos/_category_.json @@ -0,0 +1,5 @@ +{ + "label": "How-tos", + "position": 3, + "collapsed": true +} diff --git a/hub-docs/howtos/autoscaling.md b/hub-docs/howtos/autoscaling.md new file mode 100644 index 000000000..85d0b8766 --- /dev/null +++ b/hub-docs/howtos/autoscaling.md @@ -0,0 +1,62 @@ +--- +title: Autoscaling +sidebar_position: 13 +description: Scale hub-core with the Horizontal Pod Autoscaler. +--- + +## Horizontal pod autoscaler + +The chart includes an opt-in `HorizontalPodAutoscaler` for `hub-core`. When +enabled, the HPA scales the `hub-core` Deployment based on observed CPU +utilization against the Pod's CPU request. + +Enable it in your `values.yaml`: + +```yaml +hub-core: + api: + resources: + requests: + cpu: 500m + memory: 512Mi + limits: + cpu: 1000m + memory: 1Gi + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 70 +``` + +The HPA requires a running Kubernetes metrics server in the cluster. +Without it, the HPA can't read CPU metrics and doesn't scale. + + +:::note +The HPA needs CPU `requests` set on the `hub-core` container to compute +utilization. The chart leaves `resources` empty by default. Set requests and +limits explicitly before enabling the HPA, or scaling decisions are +undefined. +::: + + +### Custom metrics + +CPU and memory are the only metrics the chart wires today. Hub-specific signals +(hub-core query rate, hub-connector ingestion backlog, PostgreSQL connection +saturation) are available via Prometheus. These aren't yet integrated into +the chart's HPA template. If you need to scale on those signals, install a +custom metrics adapter such as the Prometheus Adapter. Then manage your own +`HorizontalPodAutoscaler` resource alongside the chart. Native chart support for +custom metrics is on the roadmap. + +## Next step + +- [Production overview][production-overview]. Review the rest of the hardening + checklist. +- [High availability][high-availability]. Pair autoscaling with replica + counts, PodDisruptionBudgets, and anti-affinity. + +[high-availability]: /hub/howtos/high-availability +[production-overview]: /hub/howtos/production-overview diff --git a/hub-docs/howtos/configure-kubectl.md b/hub-docs/howtos/configure-kubectl.md new file mode 100644 index 000000000..f4f453f7f --- /dev/null +++ b/hub-docs/howtos/configure-kubectl.md @@ -0,0 +1,269 @@ +--- +title: Configure kubectl for the hub +sidebar_position: 6 +description: Configure a kubectl context that accesses the Upbound Platform hub. +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## Overview + +This page configures a kubectl context named `hub` that talks to the Upbound +Platform hub. The hub serves a Kubernetes-style API, so once you configure +kubectl, standard commands like `get` and `describe` work with hub resources +like `controlplanes`, `spaces`, and `resources`. + +## Prerequisites + +- The hub's API URL, such as `https://`. If the hub isn't served with a + publicly trusted certificate, you also need its CA certificate. +- An identity in an OIDC provider registered with the hub, for interactive login. + For machine or CI environments, provide an IdP-issued JWT instead, covered + under [Log in to the hub](#step-2-log-in-to-the-hub). +- [kubectl](https://kubernetes.io/releases/download/#kubectl) installed. + +## Step 1: Install hub-credential-helper + +`hub-credential-helper` is a CLI that authenticates you to the hub. It runs the +device-authorization and token-exchange flows to get a hub token, caches it, and +refreshes it as it expires. + +Download the binary for your platform: + + + + +```bash +os=$(uname -s | tr '[:upper:]' '[:lower:]') +arch=$(uname -m) +case "$arch" in + x86_64) arch=amd64 ;; + aarch64) arch=arm64 ;; +esac +curl -fsSLo hub-credential-helper \ + "https://storage.googleapis.com/upbound-hub-artifacts/main/current/bin/${os}_${arch}/hub-credential-helper" +chmod +x hub-credential-helper +sudo mv hub-credential-helper /usr/local/bin/ +``` + + + + +```bash +curl -fsSLo hub-credential-helper \ + "https://storage.googleapis.com/upbound-hub-artifacts/main/current/bin/darwin_arm64/hub-credential-helper" +chmod +x hub-credential-helper +sudo mv hub-credential-helper /usr/local/bin/ +``` + + + + +```bash +curl -fsSLo hub-credential-helper \ + "https://storage.googleapis.com/upbound-hub-artifacts/main/current/bin/darwin_amd64/hub-credential-helper" +chmod +x hub-credential-helper +sudo mv hub-credential-helper /usr/local/bin/ +``` + + + + +```bash +curl -fsSLo hub-credential-helper \ + "https://storage.googleapis.com/upbound-hub-artifacts/main/current/bin/linux_amd64/hub-credential-helper" +chmod +x hub-credential-helper +sudo mv hub-credential-helper /usr/local/bin/ +``` + + + + +```bash +curl -fsSLo hub-credential-helper \ + "https://storage.googleapis.com/upbound-hub-artifacts/main/current/bin/linux_arm64/hub-credential-helper" +chmod +x hub-credential-helper +sudo mv hub-credential-helper /usr/local/bin/ +``` + + + + +Verify the install: + +```bash +hub-credential-helper --help +``` + +`hub-credential-helper --help` also documents the full flag set, environment +variables, and token-resolution order. + +## Step 2: Log in to the hub + +Set the hub URL used by the commands that follow: + +```bash +HUB_URL=https:// +``` + +Log in: + +```bash +hub-credential-helper login --hub-url="$HUB_URL" +``` + +The helper prints a verification URL and code and opens your browser. Approve the +request there to sign in through the hub's identity provider. The helper then +caches a hub access token and a refresh token, so you don't log in again until the +refresh token expires. + +:::note[CI and headless environments] +With no browser to complete the device flow, skip the interactive login. Write an +IdP-issued JWT (such as a mounted Kubernetes ServiceAccount token) to a file and +add `--token-file=/path/to/token` to the exec args when you create the kubectl +context in the next step. The helper exchanges it for a hub token. +::: + +## Step 3: Add the hub context to your kubeconfig + +Add a `hub` context that runs `hub-credential-helper` to fetch and refresh tokens, +so kubectl authenticates on its own. + +The recommended commands merge the context into your existing kubeconfig. The +alternative writes a standalone kubeconfig file that you point `KUBECONFIG` at per +command. + + + + +Merge a `hub` context into your existing kubeconfig: + +```bash +kubectl config set-cluster hub --server="$HUB_URL" + +kubectl config set-credentials hub-user \ + --exec-api-version=client.authentication.k8s.io/v1 \ + --exec-command=hub-credential-helper \ + --exec-arg=exec-credential \ + --exec-arg=--hub-url="$HUB_URL" \ + --exec-interactive-mode=IfAvailable + +kubectl config set-context hub --cluster=hub --user=hub-user +``` + +For a hub served with a private CA, include its certificate on the cluster: + +```bash +kubectl config set-cluster hub --server="$HUB_URL" \ + --certificate-authority=/path/to/ca.crt --embed-certs=true +``` + + + + +Write a standalone kubeconfig file for the hub context: + +```bash +cat > hub.kubeconfig < + + +## Step 4: Verify the context + +Confirm the context authenticates and reaches the hub. + +`kubectl auth whoami` shows your username and groups as the hub sees them, which +confirms authentication works: + +```bash +kubectl --context=hub auth whoami +``` + +A read such as `get controlplanes` confirms the context reaches the hub API: + +```bash +kubectl --context=hub get controlplanes +``` + +## Troubleshooting + +### The helper isn't found + +kubectl runs `hub-credential-helper` as an exec plugin, so the binary must be on +your `PATH`: + +```bash +command -v hub-credential-helper +``` + +If this prints nothing, reinstall the binary or move it onto your `PATH`. + +### `auth whoami` fails + +Check the identity the hub resolves for you: + +```bash +kubectl --context=hub auth whoami +``` + +A failure points to the wrong hub URL (check `--hub-url` and the cluster +`server`) or an identity provider the hub doesn't recognize. + +### Certificate errors reaching the hub + +A hub served with a private CA needs its certificate in the cluster stanza. +Confirm you set `--certificate-authority` (or `certificate-authority` in the +kubeconfig file) to the correct path. + +### Commands stop working after a while + +Your cached tokens expired. Log in again: + +```bash +hub-credential-helper login --hub-url="$HUB_URL" +``` + +## Next steps + +- [Connect a control plane](connect-control-plane.md): Register a control plane + and deploy a connector to observe its resources. +- [Connect a space](connect-space.md): Register a space and deploy a connector to + observe its resources and control planes. +- [Query your fleet](../products/insights/resource-exploration/query.md): Search, + filter, and count the resources the hub aggregates from your connected control + planes. +- [RBAC and OIDC group mapping](rbac.md): Grant users and groups access across + the hub. diff --git a/hub-docs/howtos/connect-control-plane.md b/hub-docs/howtos/connect-control-plane.md new file mode 100644 index 000000000..f23104df2 --- /dev/null +++ b/hub-docs/howtos/connect-control-plane.md @@ -0,0 +1,280 @@ +--- +title: Connect a control plane +sidebar_position: 4 +description: Connect a control plane to Upbound Platform to observe its resources. +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +Connecting a Kubernetes cluster as a control plane to the Upbound platform +allows you to observe its resources in the Console or hub API. + +:::note +To connect control planes managed by Upbound Spaces, see [how to connect a +space](connect-space.md). +::: + +## Prerequisites + +- A control plane to connect: the Kubernetes cluster whose resources you want to + observe, and a kubeconfig context for it. +- Upbound Platform hub, reachable from the control plane. +- A realm for the control plane, and admin permissions within that realm. +- Access to the Console, or `kubectl` [configured with a `hub` context](configure-kubectl.md) + that targets the hub. +- [Helm](https://helm.sh/) 3. + +## Step 1: Declare the control plane + +Declare the control plane in Upbound Platform to assign its realm and name, then +get a connector registration token. + + + + +1. In the Console, open the **Control Planes** view. +2. Select **Register Control Plane**. +3. Choose the realm that owns the control plane, and enter a name for it. The + name must be unique within that realm. + + ![The Register a Control Plane dialog, with a realm selector and name field](/img/hub/connect-control-plane/register-dialog.png) + +4. Select **Register**. The Console registers the control plane and immediately + shows its registration token. Copy it now (see Step 2). + + + + +Write the control plane manifest. A control plane is namespaced, and its +namespace is the realm: + +```yaml title="controlplane.yaml" +apiVersion: hub.upbound.io/v1beta1 +kind: ControlPlane +metadata: + name: + namespace: +``` + +Create it. The registration token is only returned in the create response, so +capture it now (see Step 2): + +```bash +REGISTRATION_TOKEN=$(kubectl --context=hub create -f controlplane.yaml \ + -o jsonpath='{.status.registrationToken}') +``` + + + + +## Step 2: Get the registration token + +Capture the registration token from Step 1. Use it to deploy the +connector in Step 3. + +:::warning +The Console and kubectl return the registration token only once. +This one-time-use token is valid for 24 hours and you can't retrieve it after +creation. You may reissue the token, +which invalidates the existing token. +::: + + + + +Copy the registration token displayed after you created the control plane. The +Console will not show it again. + +![The Console showing the registration token and the next steps to deploy the connector](/img/hub/connect-control-plane/register-token.png) + +Set it as an environment variable for Step 3: + +```bash +REGISTRATION_TOKEN= +``` + +{/* +TODO(branden): Document how to reissue a registration token from the Console +once the Console supports it. See GLO-1188. +*/} + + + + +`$REGISTRATION_TOKEN` from Step 1 holds the token. Confirm it is populated: + +```bash +echo "$REGISTRATION_TOKEN" +``` + +The token from the create response cannot be read back later, but you can issue a +new token for the same control plane at any time. Issuing a new token invalidates +the previous one: + +```bash +REGISTRATION_TOKEN=$(echo '{"apiVersion":"hub.upbound.io/v1beta1","kind":"ControlPlane"}' \ + | kubectl --context=hub create --raw \ + "/apis/hub.upbound.io/v1beta1/namespaces//controlplanes//registrationtoken" \ + -f - | jq -r '.status.registrationToken') +``` + + + + +## Step 3: Deploy the connector + +Deploy the connector to the control plane. The connector completes the control +plane's registration and syncs its resources. + +Set the kubeconfig context for the control plane: + +```bash +CONTROL_PLANE_CONTEXT= +``` + +Create a namespace and a secret holding the registration token: + +```bash +kubectl --context="$CONTROL_PLANE_CONTEXT" create namespace upbound-system + +kubectl --context="$CONTROL_PLANE_CONTEXT" --namespace upbound-system \ + create secret generic hub-connector-credentials \ + --from-literal=registrationToken="$REGISTRATION_TOKEN" +``` + +Install the connector chart, pointing it at the hub: + +```bash +helm install hub-connector oci://xpkg.upbound.io/upbound/hub-connector \ + --kube-context "$CONTROL_PLANE_CONTEXT" \ + --namespace upbound-system \ + --set connector.hub.url= +``` + +Notes: + +- `connector.credentials.existingSecretRef.name` defaults to + `hub-connector-credentials`. Override it only if you named the secret + differently. +- `connector.sync.limitToClusterRoles` limits which resources the connector syncs + to those authorized by the listed ClusterRoles. It defaults to `crossplane-admin` + which only syncs Crossplane resources. Set it to `[]` to sync all resources. + +## Step 4: Verify the connector started + +Confirm the connector pods reach `Ready`: + +```bash +kubectl --context="$CONTROL_PLANE_CONTEXT" --namespace upbound-system \ + wait --for=condition=Ready pod \ + --selector app.kubernetes.io/name=hub-connector --timeout=120s +``` + +```bash +kubectl --context="$CONTROL_PLANE_CONTEXT" --namespace upbound-system \ + get pods --selector app.kubernetes.io/name=hub-connector +``` + +## Step 5: Observe from Upbound Platform + +View the control plane and its resources in Upbound Platform. + + + + +1. Open the control planes view. The control plane you created shows a status of + **Ready**. + + ![The Upbound Platform control planes view showing the connected control plane with a Ready status.](/img/hub/connect-control-plane/ready.png) + +2. Open its resources view to see the resources synced from the control plane. + + ![The control plane resources view listing the resources synced from the connected control plane.](/img/hub/connect-control-plane/resources.png) + + + + +The control plane reports phase `Ready`: + +```bash +kubectl --context=hub --namespace get controlplanes +``` + +```bash +kubectl --context=hub --namespace \ + get controlplane -o jsonpath='{.status.phase}' +``` + +List the resources synced from this control plane. The `resources` endpoint takes +a `controlPlane` filter: + +```bash +kubectl --context=hub get --raw \ + '/apis/hub.upbound.io/v1beta1/resources?filter=controlPlane==%22%22' \ + | jq -r '.items[].metadata.name' +``` + + + + +## Troubleshooting + +### The connector pods don't reach Ready + +Check the pod status and events: + +```bash +kubectl --context="$CONTROL_PLANE_CONTEXT" --namespace upbound-system \ + describe pod --selector app.kubernetes.io/name=hub-connector +``` + +The connector needs the `hub-connector-credentials` secret to exist before it +starts, and the registration token it holds must still be valid. If the token +expired or was already used, reissue it (Step 2), update the secret, and restart +the connector: + +```bash +kubectl --context="$CONTROL_PLANE_CONTEXT" --namespace upbound-system \ + rollout restart deployment hub-connector +``` + +### The control plane stays Pending + +A control plane stays `Pending` until its connector completes registration. +Check the phase: + +```bash +kubectl --context=hub --namespace \ + get controlplane -o jsonpath='{.status.phase}' +``` + +If it doesn't become `Ready`, the connector hasn't completed registration. +Confirm the connector pods are `Ready` (Step 4). If the pods are `Ready` but the +phase stays `Pending`, the connector can't reach the hub. See the next section. + +### The connector can't reach the hub + +The connector pushes to the hub at `connector.hub.url`. From the control plane, +confirm that URL is correct and reachable, and that any firewall or network +policy allows egress to the hub. Correct the value and upgrade the release: + +```bash +helm upgrade hub-connector oci://xpkg.upbound.io/upbound/hub-connector \ + --kube-context "$CONTROL_PLANE_CONTEXT" \ + --namespace upbound-system \ + --reuse-values \ + --set connector.hub.url= +``` + +### No resources appear for the control plane + +By default the connector syncs only resources authorized by the +`crossplane-admin` ClusterRole. If the resources you expect aren't covered by +that role, they don't sync. Widen `connector.sync.limitToClusterRoles`, or set +it to `[]` to sync everything, then upgrade the release. + +## Next steps + +- [RBAC and OIDC group mapping](rbac.md): Grant users and groups access to the + control plane and its resources. diff --git a/hub-docs/howtos/connect-space.md b/hub-docs/howtos/connect-space.md new file mode 100644 index 000000000..911fe24f3 --- /dev/null +++ b/hub-docs/howtos/connect-space.md @@ -0,0 +1,304 @@ +--- +title: Connect a space +sidebar_position: 5 +description: Connect a space to Upbound Platform to observe its resources and control planes. +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +Connecting a self-hosted Spaces cluster to the Upbound platform +allows you to observe its resources in the Console or hub API and automatically +connects the control planes it manages. + +Resources in a connected space appear in the `default` realm under a control +plane named `space-{space-name}`. Connected space control planes appear as +`{space-name}.{control-plane-name}` in the realm corresponding to their +namespace on the space cluster. + +:::note +To connect a control plane that's not managed by Upbound Spaces, see [how to +connect a control plane](connect-control-plane.md). +::: + +## Prerequisites + +- A space to connect: the Kubernetes cluster running Upbound Spaces whose + resources and control planes you want to observe, and a kubeconfig context for + it. +- Upbound Platform hub, reachable from the space. +- Access to the Console, or `kubectl` [configured with a `hub` context](configure-kubectl.md) + that targets the hub. +- [Helm](https://helm.sh/) 3. + +## Step 1: Declare the space + +Declare the space in Upbound Platform to assign its name and get a connector +registration token. + + + + +1. In the Console, open the spaces view. +2. Select **Create space**. +3. Enter a name for the space. +4. Submit. The Console creates the space and immediately shows its registration + token (see Step 2). + + + + +Write the space manifest. A space is cluster-scoped and has an empty spec: + +```yaml title="space.yaml" +apiVersion: hub.upbound.io/v1beta1 +kind: Space +metadata: + name: k +``` + +Create it. The registration token is only returned in the create respo{}nse, so +capture it now (see Step 2): + +```bash +REGISTRATION_TOKEN=$(kubectl --context=hub create -f space.yaml \ + -o jsonpath='{.status.registrationToken}') +``` + + + + +## Step 2: Get the registration token + +Capture the registration token from Step 1. Use it to deploy the +connector in Step 3. + + +:::warning +The Console and kubectl return the registration token only once. +This one-time-use token is valid for 24 hours and you can't retrieve it after +creation. You may reissue the token, +which invalidates the existing token. +::: + + + + +Copy the registration token displayed after you created the space. The Console +will not show it again. Set it as an environment variable for Step 3: + +```bash +REGISTRATION_TOKEN= +``` + +{/* +TODO(branden): Document how to reissue a registration token from the Console +once the Console supports it. See GLO-1188. +*/} + + + + +`$REGISTRATION_TOKEN` from Step 1 holds the token. Confirm it is populated: + +```bash +echo "$REGISTRATION_TOKEN" +``` + +The token from the create response cannot be read back later, but you can issue a +new token for the same space at any time. Issuing a new token invalidates the +previous one: + +```bash +REGISTRATION_TOKEN=$(echo '{"apiVersion":"hub.upbound.io/v1beta1","kind":"Space"}' \ + | kubectl --context=hub create --raw \ + "/apis/hub.upbound.io/v1beta1/spaces//registrationtoken" \ + -f - | jq -r '.status.registrationToken') +``` + + + + +## Step 3: Deploy the connector + +Deploy the connector to the space. The connector completes the space's +registration, syncs its resources, and connects its control planes. + +Set the kubeconfig context for the space cluster: + +```bash +SPACE_CONTEXT= +``` + +Create a namespace and a secret holding the registration token on the space +cluster: + +```bash +kubectl --context="$SPACE_CONTEXT" create namespace upbound-system + +kubectl --context="$SPACE_CONTEXT" --namespace upbound-system \ + create secret generic hub-connector-credentials \ + --from-literal=registrationToken="$REGISTRATION_TOKEN" +``` + +Install the connector chart with Spaces integration enabled: + +```bash +helm install hub-connector oci://xpkg.upbound.io/upbound/hub-connector \ + --kube-context "$SPACE_CONTEXT" \ + --namespace upbound-system \ + --set connector.spaces.enabled=true \ + --set connector.hub.url= +``` + +Notes: + +- `connector.credentials.existingSecretRef.name` defaults to + `hub-connector-credentials`. Override it only if you named the secret + differently. +- Override the provisioned connectors' settings under + `connector.spaces.controlPlaneConnector.*` (for example `excludeResources` or + `resources`). Each field defaults to the space connector's own configuration. + +## Step 4: Verify the connector started + +Confirm the connector pods reach `Ready`: + +```bash +kubectl --context="$SPACE_CONTEXT" --namespace upbound-system \ + wait --for=condition=Ready pod \ + --selector app.kubernetes.io/name=hub-connector --timeout=120s +``` + +The space connector deploys a per-control-plane connector into each control +plane's host namespace. Confirm those pods are `Ready` as well: + +```bash +kubectl --context="$SPACE_CONTEXT" --all-namespaces \ + get pods --selector app.kubernetes.io/name=hub-connector +``` + +## Step 5: Observe from Upbound Platform + +View the space, its resources, and control planes in Upbound Platform. + + + + +1. Open the spaces view. The space you created shows a status of **Ready**. + + ![The Upbound Platform spaces view showing the connected space with a Ready status.](/img/hub/connect-space/ready.png) + +2. Open the control planes view. Two kinds of control plane appear for the space: + - The representative control plane for the space, named `space-`, + in the `default` realm. + - The space's member control planes, named `.`. + Each is created in the realm that matches its namespace in the space. + + ![The control planes view showing the space representative control plane alongside its member control planes.](/img/hub/connect-space/control-planes.png) + +3. Open a control plane's resources view to see the resources synced from that + control plane. + + ![A member control plane resources view listing its synced resources.](/img/hub/connect-space/resources.png) + + + + +The space reports phase `Ready` and a populated `controlPlaneRef`: + +```bash +kubectl --context=hub get spaces +``` + +```bash +kubectl --context=hub get space -o jsonpath='{.status.phase}' +``` + +The member control planes appear as `.` in their +realms: + +```bash +kubectl --context=hub --all-namespaces get controlplanes +``` + +List the resources synced across this space. The `resources` endpoint takes a +`space` filter: + +```bash +kubectl --context=hub get --raw \ + '/apis/hub.upbound.io/v1beta1/resources?filter=space==%22%22' \ + | jq -r '.items[].metadata.name' +``` + + + + +## Troubleshooting + +### The space connector pods don't reach Ready + +Check the pod status and events: + +```bash +kubectl --context="$SPACE_CONTEXT" --namespace upbound-system \ + describe pod --selector app.kubernetes.io/name=hub-connector +``` + +The connector needs the `hub-connector-credentials` secret to exist before it +starts, and the registration token it holds must still be valid. If the token +expired or was already used, reissue it (Step 2), update the secret, and restart +the connector: + +```bash +kubectl --context="$SPACE_CONTEXT" --namespace upbound-system \ + rollout restart deployment hub-connector +``` + +### The space stays `Pending` + +A space stays `Pending` until its connector completes registration. Check the +phase: + +```bash +kubectl --context=hub get space -o jsonpath='{.status.phase}' +``` + +If it doesn't become `Ready`, the connector hasn't completed registration. +Confirm the space connector pods are `Ready` (Step 4). If the pods are `Ready` +but the phase stays `Pending`, the connector can't reach the hub. See the next +section. + +### The connector can't reach the hub + +The connector pushes to the hub at `connector.hub.url`. From the space cluster, +confirm that URL is correct and reachable, and that any firewall or network +policy allows egress to the hub. Correct the value and upgrade the release: + +```bash +helm upgrade hub-connector oci://xpkg.upbound.io/upbound/hub-connector \ + --kube-context "$SPACE_CONTEXT" \ + --namespace upbound-system \ + --reuse-values \ + --set connector.hub.url= +``` + +### Member control planes don't appear + +The connector deploys a per-control-plane connector into each control plane's +system namespace on the space cluster. If the space is `Ready` but its member +control planes don't appear in the hub, confirm the per-control-plane +connectors came up: + +```bash +kubectl --context="$SPACE_CONTEXT" --all-namespaces \ + get pods --selector app.kubernetes.io/name=hub-connector +``` + +If those connectors are missing, confirm the Spaces installation is healthy and +that the control planes you expect exist on the space cluster. + +## Next steps + +- [RBAC and OIDC group mapping](rbac.md): Grant users and groups access to the + space and its control planes. diff --git a/hub-docs/howtos/databases/_category_.json b/hub-docs/howtos/databases/_category_.json new file mode 100644 index 000000000..6ded53479 --- /dev/null +++ b/hub-docs/howtos/databases/_category_.json @@ -0,0 +1,5 @@ +{ + "label": "Databases", + "position": 2, + "collapsed": true +} diff --git a/hub-docs/howtos/databases/aws-rds.md b/hub-docs/howtos/databases/aws-rds.md new file mode 100644 index 000000000..725e51ecd --- /dev/null +++ b/hub-docs/howtos/databases/aws-rds.md @@ -0,0 +1,307 @@ +--- +title: AWS RDS +sidebar_position: 2 +description: Provision Amazon RDS for PostgreSQL and connect Hub with IAM auth. +--- + +Provision Amazon RDS for PostgreSQL, grant Hub a database role that +authenticates with AWS IAM, then record the resulting endpoint and region for +the install. + +IAM authentication is the recommended path for self-hosted Hub on AWS. Rather +than storing a static database password in your cluster, the `hub-core` Pods +exchange a short-lived RDS auth token per database connection, using credentials +from IAM Roles for Service Accounts (IRSA) or EKS Pod Identity. + +If you can't use IAM in your environment, review the [password-auth +fallback](#password-authentication-fallback) instructions. + +## Prerequisites + +Before you begin, have the following ready: + +- An AWS account with permissions to create RDS instances, IAM roles, IAM + policies, and (if you don't already have one) an OIDC provider for your EKS + cluster. +- A Kubernetes cluster running on AWS with a working AWS workload-identity + mechanism: either [IAM Roles for Service + Accounts][iam-roles-for-service-accounts] + on EKS or [EKS Pod + Identity][eks-pod-identity]. + Self-managed clusters can use IRSA as long as the API server's + service-account-issuer is registered as an IAM OIDC provider. +- `psql` (or another PostgreSQL client) installed locally, reachable to the RDS + instance for the one-time bootstrap. A bastion or VPN is fine. + + +:::note +This page assumes you have already read [the self-hosted +overview][architecture] and [the database overview][overview]. It does +not repeat generic Postgres requirements covered there. +::: + + + +## Provision RDS + + + +Provision the database with whichever IaC tool you already use. This section +lists only the settings that matter for Hub. + +Read these AWS guides end-to-end before provisioning: + +- [Creating a PostgreSQL DB instance][creating-a-postgresql-db-instance] +- [IAM database authentication for MariaDB, MySQL, and PostgreSQL][iam-database-authentication-for-mariadb-mysql-and-postgresql] +- [VPC security groups for RDS][vpc-security-groups-for-rds] + +When you create the instance, set the following: + +- **Engine**: PostgreSQL 18 or newer. +- **IAM database authentication**: enabled. This setting works as a per-instance + flag. You must enable it at the instance level before you can use it for any + single database or role. + - **Network**: place the instance in private subnets + and attach a security group that allows inbound TCP 5432 from the security group attached to the worker + nodes that run `hub-core`. +- **TLS**: RDS terminates TLS by default. Note the CA bundle you need to + trust, since RDS rotates these on a published schedule. + +Record three values once the instance is available: + +- the RDS endpoint hostname (for example,`hub.cluster-xxxx.us-east-1.rds.amazonaws.com`). +- the instance's **DbiResourceId** (a string starting with `db-…`). You can find + it in the RDS console under the instance's **Configuration** tab, or via `aws + rds describe-db-instances`. An IAM policy you create later targets this ID, + not the instance name. +- the AWS region (for example, `us-east-1`). + +:::warning +Enabling IAM database authentication on an existing instance triggers a reboot, +so schedule it during a maintenance window. +::: + +## Configure IAM authentication + +IAM auth for RDS has three sides that must agree: + +1. An IAM role the `hub-core` Pod can assume. +2. A policy on that role granting `rds-db:connect` for the database user Hub + logs in as. +3. A PostgreSQL role with the same name as the IAM user, granted the `rds_iam` role inside the database. + +### Create the database role + +Connect to the instance as the master user and create the role Hub uses. This +role needs the `rds_iam` grant so RDS accepts IAM-issued tokens for it, as +described in [Creating a database account using IAM +authentication][iam-db-accounts]. It also needs ownership of the Hub database so +migrations can create and alter objects. + +```sql +CREATE DATABASE hub; +CREATE USER hub; +GRANT rds_iam TO hub; +GRANT ALL PRIVILEGES ON DATABASE hub TO hub; +\c hub +GRANT ALL ON SCHEMA public TO hub; +``` + +Hub's migrations create the `hstore` extension on first run, which requires +database-owner or superuser privileges. You can either grant ownership of the +database to `hub`: + +```sql +ALTER DATABASE hub OWNER TO hub; +``` + +or pre-create the extension as the master user and skip the ownership grant: + +```sql +\c hub +CREATE EXTENSION IF NOT EXISTS hstore; +``` + +:::note +`rds_iam` and password authentication are mutually exclusive on RDS. A role +granted `rds_iam` can't also log in with a password. Don't set a password on +`hub`. +::: + +### Create the IAM policy + +Follow [Creating and using an IAM policy for IAM database +access][iam-policy-for-db-access]. Hub needs one statement, allowing +`rds-db:connect` on the `hub` database role: + +| Field | Value for Hub | +| --- | --- | +| Action | `rds-db:connect` | +| Resource | `arn:aws:rds-db:::dbuser:/hub` | + +The final segment of the resource ARN is the database role name, `hub`, and +`` is the `db-…` string you recorded above, not the instance +name. + +Record the resulting policy ARN. You attach it to the role in the next section. + +### Bind the policy to the hub-core ServiceAccount + +Attach the policy to an IAM role the `hub-core` Pod can assume. AWS offers two +mechanisms. IRSA works on any EKS cluster with an IAM OIDC provider. Pod Identity +is simpler to manage where it's available. + +Whichever you pick, the role identifies the Pod by its Kubernetes +ServiceAccount. The chart creates that ServiceAccount, so these values are what +AWS's procedure needs: + +| What the AWS procedure asks for | Value for Hub | +| --- | --- | +| Namespace | `hub`, or the namespace you install the release into | +| ServiceAccount name | `hub-core` | +| IRSA trust policy `sub` condition | `system:serviceaccount:hub:hub-core` | +| IRSA trust policy `aud` condition | `sts.amazonaws.com` | + + +#### Option A: IAM roles for service accounts (IRSA) + + +Follow [Assign IAM roles to Kubernetes service accounts][associate-sa-role], +using the subject and audience conditions from the table above. If your +cluster's OIDC issuer isn't registered with IAM yet, do [Create an IAM OIDC +provider][create-an-iam-oidc-provider] first. + +You then annotate the ServiceAccount with the role ARN through Helm values, +shown in the next section. + +#### Option B: EKS pod identity + +Follow [Assign an IAM role to a Kubernetes service +account][pod-id-association], creating the association against namespace `hub` +and ServiceAccount `hub-core`. Create the association after you install Hub, so +the ServiceAccount exists. + + +With Pod Identity the ServiceAccount needs no annotations. EKS keys the +association by namespace and ServiceAccount name, so leave the +`eks.amazonaws.com/role-arn` annotation out of your Helm values. + +## Values to record + +The IAM-auth values omit any password Secret. `hub-core` reads the auth mode +and cloud from environment variables emitted by the chart. It then builds an RDS +auth token from the Pod's IAM credentials. It uses that token as the PostgreSQL +password on every new pool connection. TLS is required: the chart forces +`sslmode=require` whenever IAM mode is selected, even if you leave +`postgresql.sslmode` unset. + + +Record these values and enter them in the **AWS IAM authentication** tab in step +4 of [the install guide][install]: + +| Value | Where it came from | +| --- | --- | +| RDS endpoint hostname | The provisioned instance | +| AWS region | The provisioned instance | +| Database name and user | `hub` and `hub`, from the SQL above | +| IAM role ARN | The role you attached the policy to. IRSA only | + +The completed block should look like this: + +```yaml +hub-core: + api: + serviceAccount: + create: true + # Only needed for IRSA. Pod Identity does not use ServiceAccount annotations. + annotations: + eks.amazonaws.com/role-arn: arn:aws:iam:::role/hub-core + + postgresql: + host: + port: 5432 + database: hub + user: hub + sslmode: require + + auth: + mode: iam + cloud: aws + aws: + region: +``` + +:::note +The chart ignores its `postgresql.connectionString` value when `auth.mode=iam`. +Use the discrete `host`, `port`, `database`, and `user` fields. +::: + +## Troubleshoot IAM auth + +Once you've installed Hub, watch the `hub-core` Pods roll out: + +```bash +kubectl -n hub rollout status deployment/hub-core +``` + +The Pod runs the schema migrator as an init container before the API server +starts. Both use the same IAM auth path, so a successful rollout means both the +migrator and the server authenticated against RDS. + +If the Pod is crash-looping, check its logs for one of these: + +- `IAM database auth requires TLS`. `postgresql.sslmode` was set to `disable`. + Remove the override or set it to `require`. +- `database auth aws region is required`. `postgresql.auth.aws.region` is empty + and the Pod has no `AWS_REGION` environment variable. Set the value in + `values.yaml`. +- `PAM authentication failed` (in the RDS log). The IAM role is missing + `rds-db:connect` for the resource, the DbiResourceId in the policy is wrong, + or the database role wasn't granted `rds_iam`. + +Log in to Hub and confirm at least one ControlPlane has been registered +or can be created. Schema is in place when the UI lists resources without +errors. + + +## Password authentication (fallback) + +Use this section only if you can't use IAM authentication, such as when running +outside AWS or on a Kubernetes cluster without workload identity. Password mode +stores a long-lived credential in a Secret. Rotate it through whatever +secret-management tool your organization already uses. + +Create the database role with a password rather than the `rds_iam` +grant: + +```sql +CREATE USER hub WITH PASSWORD ''; +GRANT ALL PRIVILEGES ON DATABASE hub TO hub; +``` + +Then use the **Password authentication** tab in step 4 of [the install +guide][install] instead of the IAM tab, with the RDS endpoint as the host. Step +2 of that guide creates the Secret holding the password you set above. Skip the +IAM role, policy, and ServiceAccount setup on this page entirely. + +Everything else (TLS, security groups, schema bootstrap) works the same as the +IAM path. + +## Next step + +Return to [the self-hosted install guide][install] to finish wiring Hub +against the database you just provisioned. + +[architecture]: /hub/concepts/architecture +[create-an-iam-oidc-provider]: https://docs.aws.amazon.com/eks/latest/userguide/enable-iam-roles-for-service-accounts.html +[creating-a-postgresql-db-instance]: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/CHAP_GettingStarted.CreatingConnecting.PostgreSQL.html +[eks-pod-identity]: https://docs.aws.amazon.com/eks/latest/userguide/pod-identities.html +[iam-database-authentication-for-mariadb-mysql-and-postgresql]: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html +[iam-policy-for-db-access]: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.IAMPolicy.html +[iam-roles-for-service-accounts]: https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html +[associate-sa-role]: https://docs.aws.amazon.com/eks/latest/userguide/associate-service-account-role.html +[pod-id-association]: https://docs.aws.amazon.com/eks/latest/userguide/pod-id-association.html +[iam-db-accounts]: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.DBAccounts.html +[install]: /hub/howtos/install +[overview]: /hub/howtos/databases/overview +[vpc-security-groups-for-rds]: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Overview.RDSSecurityGroups.html diff --git a/hub-docs/howtos/databases/overview.md b/hub-docs/howtos/databases/overview.md new file mode 100644 index 000000000..d4e52d887 --- /dev/null +++ b/hub-docs/howtos/databases/overview.md @@ -0,0 +1,193 @@ +--- +title: Database setup +sidebar_position: 1 +description: PostgreSQL Requirements and authentication modes for Hub. +--- + +Hub requires an externally managed PostgreSQL instance. You provision the +instance with a database and role for Hub, then record the connection details. + +You don't write any `values.yaml` on this page. [The install +guide][install] owns that file. The YAML on this page shows the shape of the +Postgres block so you know which values to record, and you paste the completed +block into `values.yaml` in step 4 of the install. + + +## PostgreSQL requirements + + +Pods in the `hub-core` will initially connect to the PostgreSQL instance to run +schema migrations, then will maintain long-lived connections to the database to +serve APIs. + +Your database must have: + +- **PostgreSQL version**: 18 or newer. Hub's schema uses Postgres 18's native + `uuidv7()` function as the default for primary keys, so earlier major versions + can't run the migrations. Any actively supported 18.x release is acceptable, + including managed offerings (AWS RDS, GCP Cloud SQL, Azure Database for + PostgreSQL) on PostgreSQL 18 engine versions. +- **Required extensions**: `hub-core` runs `CREATE EXTENSION IF NOT EXISTS + hstore` from its first migration. The role that runs migrations needs + privileges to create that extension, or a database administrator must create + it once before install: + + ```sql + CREATE EXTENSION IF NOT EXISTS hstore; + ``` + + `hstore` comes with PostgreSQL's `contrib` modules and is available on every + mainstream managed Postgres service. +- **A dedicated database and role**: provision a database for Hub and a role + with full privileges on it. Hub doesn't share schemas with other applications. +- **TLS**: highly recommended for any production deployment. IAM authentication + (see below) forces TLS regardless of the `sslmode` Helm value. +- **Network reachability**: the database must be reachable from the namespace + where the Hub pods run within the Kubernetes cluster. For cloud-managed + databases, this usually means the cluster's nodes (or the workload's egress + NAT) sit in a network that the database's security group or firewall allows. + +:::note +Hub holds a small connection pool per `hub-core` Pod. Plan for `replicas x +pool_size` connections from the application tier, plus the migration job that +runs on every `helm upgrade`. The pool size is conservative by default. +::: + +## Instance sizing + +Size the instance for connection count and burst CPU during ingest, not for data +volume. Hub's per-resource footprint is small, so even large installations stay +within a modest volume that fits in the instance's cache. + +Start at 20 to 50 GiB of SSD storage with autoscaling enabled. The [sizing +guide][sizing] gives instance classes for AWS RDS, GCP Cloud SQL, and Azure +Database for PostgreSQL, based on your total resource count. + +Enable `pg_stat_statements` and `auto_explain`, along with your provider's query +insights feature, and keep them on in production. Hub's database load is dominated +by the resource list query, and statement-level data is what makes a latency +problem diagnosable. + +## Authentication modes + +The chart exposes two authentication modes through +`hub-core.postgresql.auth.mode`. Pick one before you provision the database role. + +### Password authentication + +Set `hub-core.postgresql.auth.mode=password` (the default). `hub-core` connects +using a static username and password. + +You can supply the password two ways: + +- **Kubernetes Secret reference (recommended).** Create a Secret in the Hub + namespace containing the password under a key, then point the chart at it: + + ```yaml + hub-core: + postgresql: + host: + port: 5432 + database: + user: + sslmode: require + auth: + mode: password + password: + existingSecretRef: + name: hub-core-postgres + key: password + ``` + + The install guide creates this Secret as `hub-core-postgres` in step 2. Any + Secret name works as long as it matches what you set here. + +- **Inline value (not recommended).** Pass the password directly via + `hub-core.postgresql.auth.password.value`. The chart writes the value into the rendered + manifest, which means it appears in `helm get values`, in any CD tool's + manifest cache, and in cluster audit logs. Use this only for quick + experiments. + +Password mode works against any PostgreSQL, whether self-managed, AWS RDS, GCP +Cloud SQL, or Azure Database for PostgreSQL. + +### Cloud IAM authentication + +Set `hub-core.postgresql.auth.mode=iam` and `hub-core.postgresql.auth.cloud=aws` +to authenticate using short-lived IAM tokens minted per connection. No static +password is stored anywhere, and the hub forces `sslmode=require` at minimum, since +cloud Postgres providers reject IAM auth over plaintext. + + +```yaml +hub-core: + postgresql: + host: + port: 5432 + database: + user: + sslmode: require + auth: + mode: iam + cloud: aws + aws: + region: +``` + +The chart wires the IAM credentials through the `hub-core` ServiceAccount. The +underlying credential source is whatever the cluster provides (for AWS: IRSA or +EKS Pod Identity). The provider-specific sub-page covers the exact setup. + +:::note +IAM auth requires you link the database role to the IAM identity at the +PostgreSQL side (such as `GRANT rds_iam TO hub;` on AWS RDS). The provider +sub-page walks through this. +::: + +## Choose your provider + +The chart's auth seam is provider-neutral, but only **AWS RDS** is wired +end-to-end today. See the provider page for full provisioning and IAM setup +steps: + +- [AWS RDS][aws-rds], which covers provisioning PostgreSQL on Amazon RDS and + connecting Hub using either password or IAM authentication. + +**GCP Cloud SQL** and **Azure Database for PostgreSQL** are both supported today +through the password authentication path described above. Point Hub at the +instance's connection endpoint, provide the password via a Kubernetes Secret, +and Hub treats them like any other PostgreSQL. Their managed IAM-authentication +paths (Cloud SQL IAM database authentication and Azure AD authentication for +PostgreSQL) are on the roadmap but not yet implemented. The values +`hub-core.postgresql.auth.cloud=gcp` and `=azure` are reserved for that work and +aren't accepted today. + + +If you self-manage Postgres, treat it the same way. Provision the role and +password yourself, then point Hub at the host with password mode. + +## Values to record + +Whichever provider and authentication mode you pick, carry these into step 4 of +[the install guide][install]: + +| Value | Notes | +| --- | --- | +| Authentication mode | `password` or `iam`. Picks which tab you use in step 4 | +| Host and port | The instance endpoint | +| Database name and user | The dedicated database and role you provisioned | +| `sslmode` | `require` or stricter. Forced to `require` in IAM mode | +| Password Secret name and key | Password mode only | +| AWS region and role ARN | IAM mode only. See [AWS RDS][aws-rds] | + +## Next step + +Pick your provider and provision the database, then return to the install +procedure: + +- [Provision PostgreSQL on AWS RDS][aws-rds] +- [Install Hub against your provisioned database][install] + +[aws-rds]: /hub/howtos/databases/aws-rds +[install]: /hub/howtos/install +[sizing]: /hub/howtos/sizing diff --git a/hub-docs/howtos/high-availability.md b/hub-docs/howtos/high-availability.md new file mode 100644 index 000000000..5aec1a167 --- /dev/null +++ b/hub-docs/howtos/high-availability.md @@ -0,0 +1,120 @@ +--- +title: High availability +sidebar_position: 12 +description: Run Hub with redundancy across nodes and zones. +--- + +Hub survives the loss of a single Pod, node, or zone when you run `hub-core` and +`hub-webui` with multiple replicas and spread them intentionally. + +## Prerequisites + + +- A self-hosted Hub install per [the self-hosted install + guide][install]. +- A worker pool with at least three nodes spread across separate failure + domains. Node labels for `topology.kubernetes.io/zone` (or an equivalent label + your cluster sets) are required for zone-level anti-affinity. +- An externally managed PostgreSQL with its own HA story. Hub itself goes + stateless across replicas, so cluster availability is bounded by the database + tier. See [the database overview][overview]. +- Sized resource requests for `hub-core`. Pick a tier in [sizing][sizing] + before scaling out. Replicas without requests don't pack onto separate + nodes reliably. + + + +HA applies to `hub-core` and `hub-webui`. The `hub-connector` runs as a singleton +in each observed control plane by design and isn't horizontally scaled. + +## Configure replicas + +Set the replica count for `hub-core` (and, if you installed the UI, for +`hub-webui`): + +```yaml +hub-core: + api: + replicaCount: 3 + +hub-webui: + replicaCount: 2 +``` + +Three `hub-core` replicas is the smallest count that survives a node drain while +keeping a quorum of Ready Pods serving traffic. `hub-core` is stateless (every +replica reads and writes the same PostgreSQL backend), so you can scale up +further without coordination. The UI is a static asset server. Two replicas are +enough for redundancy. + +## Configure pod disruption budgets + +The chart renders a PodDisruptionBudget for `hub-core`, but leaves it off by +default. Enable it once you run more than one replica so voluntary disruptions +(node drains, cluster upgrades) can't evict every replica at once: + +```yaml +hub-core: + api: + pdb: + create: true + minAvailable: 2 +``` + +The `hub-webui` chart carries the same budget. Enable it if you scaled the UI: + +```yaml +hub-webui: + pdb: + create: true + minAvailable: 1 +``` + +See the [Kubernetes PodDisruptionBudget +docs][kubernetes-poddisruptionbudget-docs] for the +full field reference. + +## Configure anti-affinity + +Spread replicas across nodes and zones so a single failure domain can't take +down every Pod: + +```yaml +hub-core: + api: + affinity: + podAntiAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + - labelSelector: + matchLabels: + app.kubernetes.io/name: hub-core + app.kubernetes.io/instance: hub + topologyKey: kubernetes.io/hostname + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app.kubernetes.io/name: hub-core + app.kubernetes.io/instance: hub + topologyKey: topology.kubernetes.io/zone +``` + +The required term keeps two `hub-core` Pods off the same node. The preferred term +steers the scheduler toward different zones when capacity allows, without making +the Pods unschedulable if a zone is full. If you operate in a single zone, drop +the preferred term. See the [Kubernetes affinity and anti-affinity +docs][kubernetes-affinity-and-anti-affinity-docs] +for alternative topology keys. + +## Next step + +- [Autoscaling][autoscaling]. Let `hub-core` grow and shrink with load instead + of running a fixed replica count. + +[autoscaling]: /hub/howtos/autoscaling +[install]: /hub/howtos/install +[kubernetes-affinity-and-anti-affinity-docs]: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#affinity-and-anti-affinity +[kubernetes-poddisruptionbudget-docs]: https://kubernetes.io/docs/concepts/workloads/pods/disruptions/ +[overview]: /hub/howtos/databases/overview +[sizing]: /hub/howtos/sizing diff --git a/hub-docs/howtos/install.md b/hub-docs/howtos/install.md new file mode 100644 index 000000000..e4e10ad5b --- /dev/null +++ b/hub-docs/howtos/install.md @@ -0,0 +1,412 @@ +--- +title: Installing Hub +sidebar_position: 2 +description: Install Hub with Helm against external Postgres and OIDC. +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +A self-hosted Hub install needs a PostgreSQL database, an OIDC provider, and +ingress routes for the Hub services. + +This page owns the values used to install the `hub` Helm chart. The previous pages set up those dependencies and tell you which values to +record. + +## Prerequisites + +Before starting, work through [the prerequisites page][prerequisites] and +confirm each item is ready. This page assumes you have: + +- A Kubernetes cluster with a Gateway API or Ingress controller installed and a + real CA-signed TLS certificate for the hostnames you intend to use +- DNS records for the hostnames `api.` and `ui.` + pointing at the cluster's load balancer or Gateway address +- A provisioned PostgreSQL database, following [the databases + overview][overview] and the provider page it links to +- An OIDC provider registered with Hub's callback URL + `https://api./oidc/callback`, with a group claim configured, + following [the OIDC configuration page][oidc-configuration] + +Those two pages each end with a table of values to record. Have both tables in +front of you before you start. Between them they supply every placeholder in +step 4: + +| From the OIDC page | From the database page | +| --- | --- | +| `providerName` | Authentication mode (`password` or `iam`) | +| `issuerURL` | Database host and port | +| `clientID` | Database name and user | +| Client secret | `sslmode` | +| `groupsClaim` (if your provider deviates) | Password secret name and key, or AWS region and role ARN | +| Admin group or user names | | + +## The chart reference + +Hub installs from an umbrella chart at this OCI reference: + +```bash +oci://xpkg.upbound.io/upbound/hub +``` + +Helm resolves the latest release when you don't pass `--version`. Add +`--version ` to pin a release, which is what you want in a production +pipeline so an upgrade is a deliberate change rather than a side effect of +running the command again. + +## Install + +### 1. Create the namespace + +```bash +kubectl create namespace hub +``` + +The rest of this guide installs all Hub resources into `hub`. Substitute your +own namespace name if you prefer. + +### 2. Create the Postgres credentials secret + +Skip this step if you chose IAM authentication on the database page. IAM mode +stores no password, so it needs no Secret. + +For password authentication, create a Secret holding the database password under +the key `password`: + +```bash +kubectl -n hub create secret generic hub-core-postgres \ + --from-literal=password='' +``` + +You reference this Secret from `values.yaml` in step 4 via +`hub-core.postgresql.auth.password.existingSecretRef`. + +:::note +Hub doesn't require the Secret name `hub-core-postgres`. Any Secret in the same +namespace as the release works as long as the name and key match the +`existingSecretRef` fields you set in values. +::: + +### 3. Create the OIDC client-secret secret + + +Hub reads the OIDC client secret directly out of Helm values when it builds the +bootstrap configuration. To keep the client secret out of your `values.yaml` +file (and out of source control), pass it on the `helm install` command line via +`--set-file` or `--set`. Don't commit it. If your workflow requires the +value to live in a Kubernetes Secret you reconcile separately, create it now: + + +```bash +kubectl -n hub create secret generic hub-core-oidc \ + --from-literal=clientSecret='' +``` + +You can then pass the value to Helm at install time by extracting it from the +Secret: + +```bash +OIDC_CLIENT_SECRET=$(kubectl -n hub get secret hub-core-oidc \ + -o jsonpath='{.data.clientSecret}' | base64 -d) +``` + +The `helm install` command in step 5 uses the `OIDC_CLIENT_SECRET` variable. + +### 4. Assemble `values.yaml` + +Save the following as `values.yaml`. +It carries the ingress and OIDC settings, and you complete it with the Postgres +block for the authentication mode you chose. The template includes only the keys +you need to change for a self-hosted install. Defaults cover everything else. + +```yaml +global: + gateway: + enabled: true + # Set to true if you want this chart to render the Gateway + # resource. Leave false when you manage the Gateway separately + # and only want HTTPRoutes attached to it. + create: false + # Required when create=true. Identifies the GatewayClass your + # cluster's controller has registered (for example "envoy", + # "istio", "cilium"). + gatewayClassName: "" + # Apex domain Hub is served from. Subcharts compose + # . hostnames from this value. + domain: + # parentRef points at an existing Gateway. Remove these values + # below if you set create=true. + parentRef: + name: + namespace: + listeners: + https: + enabled: true + port: 443 + tls: + mode: Terminate + # TLS certificate Hub serves on api. and + # ui.. Reference a Secret of type + # kubernetes.io/tls in the Gateway's namespace. + certificateRefs: + - kind: Secret + name: + +hub-core: + api: + # Externally reachable base URL for hub-core. Used to compose + # the OIDC callback URI (/oidc/callback) and + # surfaced to hub-webui for browser redirects. Must match the + # redirect URI registered with your provider. + externalURL: https://api. + + # Values recorded on the OIDC configuration page. + sampleEmailBasedOIDCConfig: + providerName: oidc + issuerURL: + clientID: + # Client secret is injected on the helm install command line + # via --set in step 5, not committed here. + clientSecret: "" + # Optional. Restrict logins to a single email domain. + allowedDomain: + + # First administrators. Without at least one entry here, the first + # user to log in has no permissions and no way to grant any. + bootstrap: + admins: + - kind: Group + name: "oidc:" +``` + +Now append the Postgres block for your authentication mode. Both forms nest +under the same `hub-core:` key as the block above. + + + + +```yaml +hub-core: + postgresql: + # Either set connectionString OR host + database + user + auth. + # Leave connectionString empty to use the structured form below. + connectionString: "" + host: + port: 5432 + database: hub + user: hub + # Set to require (or stricter) when your database enforces TLS. + sslmode: require + auth: + mode: password + password: + # Reference to the Secret created in step 2. + existingSecretRef: + name: hub-core-postgres + key: password +``` + + + + +```yaml +hub-core: + api: + serviceAccount: + create: true + # IRSA only. Omit this annotation when using EKS Pod Identity. + annotations: + eks.amazonaws.com/role-arn: arn:aws:iam:::role/hub-core + + postgresql: + host: + port: 5432 + database: hub + user: hub + sslmode: require + auth: + mode: iam + cloud: aws + aws: + region: +``` + +IAM mode needs no password Secret, so step 2 doesn't apply. The chart forces +`sslmode=require` in this mode and ignores `connectionString`. See [AWS +RDS][aws-rds] for the IAM role, policy, and database-role setup these values +depend on. + + + + +Notes on the template: + +- `global.gateway.create` controls whether the chart owns the Gateway resource. + If your platform team already manages a shared Gateway, leave `create: false` + and point `parentRef` at it. If you want the chart to render a dedicated + Gateway, set `create: true` and supply `gatewayClassName`. +- `hub-core.api.externalURL` must match the hostname clients (browsers and CLI + tools) use to reach `hub-core`. +- `hub-core.bootstrap.admins` accepts `Group` and `User` entries. Group names + come from your provider's group claim and user names are the user's email, + both prefixed with `:`. From this one list the chart renders an + `OrganizationRoleBinding` bound to `org-admin` and a `RealmRoleBinding` bound + to `realm-admin` on the `default` realm. + +:::note +The chart exposes many more values than the ones shown here. See the values +reference for the full surface. Anything not set in +`values.yaml` falls back to the chart default. +::: + +### 5. Install the chart + +Install the chart with `values.yaml` and the OIDC client secret passed inline: + +```bash +helm install hub oci://xpkg.upbound.io/upbound/hub \ + --namespace hub \ + --values values.yaml \ + --set hub-core.api.sampleEmailBasedOIDCConfig.clientSecret="$OIDC_CLIENT_SECRET" +``` + +If you didn't extract the OIDC client secret into a shell variable in step 3, +substitute the literal value (quoted) for `$OIDC_CLIENT_SECRET`. + +Wait for the install to finish and for all Pods to become Ready: + +```bash +kubectl -n hub wait --for=condition=ready pod --all --timeout=5m +``` + +On first install, `hub-core` runs a migration init container against your +Postgres database before the main container starts. The wait above blocks until +that migration completes. + +## Verify the Install + +Confirm Hub is up and you can reach it before registering any control planes. + +Check that all Hub Pods are Ready: + +```bash +kubectl -n hub get pods +``` + +You should see Ready replicas for `hub-core` and `hub-webui`. The umbrella chart includes the `hub-connector` +Deployment, but `hub-core` itself doesn't use it. It activates only when you install a connector on an observed cluster. + +Open `https://ui.` in a browser. The browser should redirect to your +OIDC provider for login, then returned to the Hub UI. + +### Confirm admin access + +Signing in as a member of the group you listed in `hub-core.bootstrap.admins` +gives you organization and realm admin rights immediately. The chart rendered +both bindings during install. + + +If login completes but the UI shows "no permissions", confirm: + + +1. The group in your OIDC token matches an entry in + `hub-core.bootstrap.admins`, including the `:` prefix. +2. Your OIDC provider is sending the group claim. Inspect the JWT at the + provider's debug endpoint or in your browser's network tab. + +To grant access beyond these first administrators, see [RBAC and OIDC group +mapping][rbac]. + +A fresh install has no registered control planes. The next section adds one. + +## Configure + +With admin access confirmed, register your first ControlPlane and mint a +registration token for its connector. + +### Register the first control plane + +Apply a ControlPlane resource for the cluster (or clusters) you register a +`hub-connector` against. The example below registers a `production` ControlPlane +in the `default` realm and trusts your OIDC IdentityProvider for user +lookups on that ControlPlane. + +Save as `bootstrap-controlplane.yaml`: + + + +```yaml +apiVersion: hub.upbound.io/v1alpha1 +kind: ControlPlane +metadata: + name: production + namespace: default +spec: + identityProviders: + - name: oidc + userMappingType: Exact +``` + +Apply it: + +```bash +kubectl apply -f bootstrap-controlplane.yaml +``` + +The `spec.identityProviders[].name` value must match the `providerName` you set +in `hub-core.api.sampleEmailBasedOIDCConfig.providerName`. Hub uses that +IdentityProvider to resolve users referenced by role bindings on this +ControlPlane. + +### Mint a registration token + +After you create the ControlPlane, mint a registration token for it. The token +is what `hub-connector` presents when it first contacts `hub-core`: + +```bash +kubectl create -f - <<'EOF' +apiVersion: hub.upbound.io/v1alpha1 +kind: RegistrationToken +metadata: + generateName: production- + namespace: default +spec: + controlPlaneRef: + name: production +EOF +``` + +Read the issued token off the resource's status and store it somewhere safe. The +token is shown once. You hand it to the `hub-connector` install on the +observed cluster. The standalone connector install pattern carries over here, with the +connector's `connector.hub.url` pointing at your gateway instead of a +Docker-network address. + + +:::note +The chart also accepts `hub-core.bootstrap.files` for delivering ControlPlanes +and other Hub objects as part of the Helm release. Use it when you want the +control plane inventory version-controlled alongside the release rather than +applied by hand. +::: + +Refresh `https://ui.`. The `production` ControlPlane appears in the +ControlPlane list, with no resources yet since it has no connector attached. + +## Next step + +You have a working self-hosted Hub. Before letting traffic that matters depend +on it, work through [the production overview][production-overview] for +sizing, high availability, autoscaling, RBAC, and upgrade guidance. + +To attach a control plane, install the standalone `hub-connector` chart on the +observed cluster with the registration token you minted above. For self-hosted installs the connector's `connector.hub.url` and +`connector.hub.tokenExchangeUrl` point at the public hostname of your gateway +instead, and `connector.hub.allowInsecure` stays at its default of `false`. + +[aws-rds]: /hub/howtos/databases/aws-rds +[oidc-configuration]: /hub/howtos/oidc-configuration +[overview]: /hub/howtos/databases/overview +[prerequisites]: /hub/howtos/prerequisites +[production-overview]: /hub/howtos/production-overview +[rbac]: /hub/howtos/rbac diff --git a/hub-docs/howtos/metrics.md b/hub-docs/howtos/metrics.md new file mode 100644 index 000000000..1963fe16e --- /dev/null +++ b/hub-docs/howtos/metrics.md @@ -0,0 +1,347 @@ +--- +title: Metrics pipeline +sidebar_position: 4 +description: Enable the Metrics feature, pick a Prometheus-compatible backend, and turn on collection for connected control planes. +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +Metrics needs configuration on both sides of the connector: a gateway and +a storage backend on the hub, and a collector on each control plane you want to +collect from. This page covers both. See +[Metrics](../products/insights/metrics/overview.md) for what the feature does and how to +query it. + +Hub doesn't run the metrics store. You point the gateway at a +Prometheus-compatible backend you already operate, and the query API reads back +from the same backend. + +## Prerequisites + +Before you start, have the following ready: + +- A running Hub installation. See [Installing Hub](install.md). +- Helm access to the Hub release, so you can run `helm upgrade`. See [the chart + reference](install.md#the-chart-reference) for what `` stands for. +- At least one connected control plane or space. See [Connect a control + plane](connect-control-plane.md) and [Connect a space](connect-space.md). +- A Prometheus-compatible backend that accepts remote-write and serves the + Prometheus HTTP query API, reachable from the Hub cluster. +- The feature flag server enabled. The server is on by default; the `Metrics` + gate it serves isn't. See [Feature flags](../reference/feature-flags.md). + + +## Step 1: Choose a backend + +The gateway writes through one exporter, and the query API reads from one URL. +Both come from `hub-core.otelGateway.metrics`. + +| `backend` | Writes with | Use it for | +| --- | --- | --- | +| `prometheus` | Prometheus remote-write | Self-hosted Prometheus, Thanos, Mimir, or any remote-write endpoint. | +| `gmp` | The native Google Managed Prometheus exporter | Google Managed Prometheus. | +| `amp` | Remote-write | Amazon Managed Prometheus. | + +`queryURL` is the base URL the query API reads from, and it's separate from the +write endpoint on every backend. For self-hosted Prometheus the two usually +share a host. For managed Prometheus they don't. + +## Step 2: Configure the hub + +Enable the `Metrics` gate, the OTel gateway, and a backend. The gate turns on +the `metrics.hub.upbound.io` API group; the gateway receives, labels, and +forwards. + + + + +1. Add the gate and the gateway to your `values.yaml`. + + ```yaml + hub-core: + api: + featureFlags: + gates: + Metrics: true + otelGateway: + enabled: true + metrics: + backend: prometheus + queryURL: http://prometheus-server.monitoring.svc + prometheus: + writeEndpoint: http://prometheus-server.monitoring.svc/api/v1/write + insecure: true + ``` + +2. Confirm your Prometheus accepts remote-write. Prometheus rejects it unless + you start it with the receiver enabled: + + ```shell + prometheus --web.enable-remote-write-receiver + ``` + + + + +1. Add the gate and the gateway to your `values.yaml`. + + ```yaml + hub-core: + api: + featureFlags: + gates: + Metrics: true + otelGateway: + enabled: true + serviceAccount: + annotations: + iam.gke.io/gcp-service-account: @.iam.gserviceaccount.com + metrics: + backend: gmp + queryURL: + gmp: + project: + ``` + +2. Grant the gateway a Google identity. The exporter writes to the Google Cloud + Monitoring API over gRPC and authenticates with Application Default + Credentials, so the gateway ServiceAccount needs a Google identity holding + `roles/monitoring.metricWriter`, through Workload Identity or a mounted key. + +3. Point `queryURL` at a Prometheus-compatible read endpoint for the same data. + Google Managed Prometheus doesn't serve the Prometheus HTTP query API + directly. Deploy the [GMP frontend + proxy][gmp-frontend] and use its address. + + + + +1. Add the gate and the gateway to your `values.yaml`. + + ```yaml + hub-core: + api: + featureFlags: + gates: + Metrics: true + serviceAccount: + annotations: + eks.amazonaws.com/role-arn: arn:aws:iam:::role/ + otelGateway: + enabled: true + serviceAccount: + annotations: + eks.amazonaws.com/role-arn: arn:aws:iam:::role/ + metrics: + backend: amp + queryURL: https://aps-workspaces..amazonaws.com/workspaces/ + amp: + writeEndpoint: https://aps-workspaces..amazonaws.com/workspaces//api/v1/remote_write + region: + ``` + + `region` is required. The query API has no other source for it. + +2. Annotate both ServiceAccounts, with different permissions. The write path and + the read path resolve AWS credentials independently, in different Pods: + + | Path | ServiceAccount | IAM permission | + | --- | --- | --- | + | Write | `otelGateway.serviceAccount` | `aps:RemoteWrite` | + | Read | `api.serviceAccount` | `aps:QueryMetrics` | + + + + +Apply the values with an upgrade: + +```shell +helm upgrade hub \ + --namespace hub \ + --values values.yaml +``` + +Confirm the gateway is running: + +```shell +kubectl --namespace hub get pods --selector app.kubernetes.io/component=otel-gateway +``` + +## Step 3: Turn on collection for control planes + +The hub side receives, but nothing sends until you enable the collector on the +connector. The collector is off by default in the `hub-connector` chart. + + + + +Upgrade the connector on the control plane: + +```shell +helm upgrade hub-connector oci://xpkg.upbound.io/upbound/hub-connector \ + --kube-context "$CONTROL_PLANE_CONTEXT" \ + --namespace upbound-system \ + --reuse-values \ + --set collector.enabled=true \ + --set rsm.enabled=true +``` + + + + +A space connector provisions a metrics pipeline into each member control plane, +using the same values. Set them once on the space connector and every control +plane in the space collects: + +```shell +helm upgrade hub-connector oci://xpkg.upbound.io/upbound/hub-connector \ + --kube-context "$SPACE_CONTEXT" \ + --namespace upbound-system \ + --reuse-values \ + --set collector.enabled=true \ + --set rsm.enabled=true +``` + +The space connector applies the change to control planes as it reconciles them. +New control planes get the pipeline when it provisions their connector. + + + + +### What the collector scrapes + +The collector discovers Pods in `collector.scrapeNamespaces` and keeps only +those annotated `prometheus.io/scrape: "true"`. It scrapes port `8080` and path +`/metrics` unless the Pod overrides them with `prometheus.io/port` and +`prometheus.io/path`. + +| Value | Default | Description | +| --- | --- | --- | +| `collector.scrapeNamespaces` | `[crossplane-system, upbound-system]` | Namespaces to discover Pods in. Upstream Crossplane installs into `crossplane-system`; UXP installs into `upbound-system`. | +| `collector.scrapeInterval` | `15s` | How often to scrape each target. | +| `collector.metricAllowlist` | The curated set | Metric-name regexps that may leave the control plane. | + +### Turn on resource state metrics + +`rsm.enabled` deploys [resource-state-metrics][rsm], a SIG Instrumentation +project that turns resource status conditions into +`kube_customresource_resource_condition` gauges. It's optional and off by +default. The collector scrapes it as an extra target, and the +`kube_customresource_.*` entry in the default allowlist lets those series +through. + +`rsm.apiGroups` selects which API groups it watches, and defaults to `"*"`. + +:::warning +At `"*"` the component emits a series per condition per resource in the control +plane, including core Kubernetes objects. Around 1,000 resources produces about +9,000 series. Narrow `rsm.apiGroups` to the Crossplane groups you care about +before enabling it on a large control plane. +::: + +## Step 4: Verify + +1. Query the API group to confirm it responds. With the gate on, + `/apis/metrics.hub.upbound.io/v1alpha1` returns: + + ```json + { + "kind": "APIResourceList", + "apiVersion": "v1", + "groupVersion": "metrics.hub.upbound.io/v1alpha1", + "resources": [ + { + "name": "queries", + "singularName": "", + "namespaced": false, + "kind": "Query", + "verbs": [ + "create" + ] + } + ] + } + ``` + +2. Query for anything the pipeline should be producing. Metrics take a scrape + interval or two to appear after you enable the collector. + + ```shell + curl -sk -H "Authorization: Bearer $TOKEN" \ + -X POST "/apis/metrics.hub.upbound.io/v1alpha1/queries" \ + -H 'Content-Type: application/json' \ + -d '{ + "apiVersion": "metrics.hub.upbound.io/v1alpha1", + "kind": "Query", + "query": { + "promql": "count(controller_runtime_reconcile_total) by (control_plane_name)", + "start": "2026-07-29T12:00:00Z", + "end": "2026-07-29T13:00:00Z", + "step": "5m" + } + }' | jq '.results.series[].labels' + ``` + + One series per collecting control plane means the whole path works. + +## Add metrics to the allowlist + +Both sides filter, so a metric needs adding in two places. The collector's +`collector.metricAllowlist` decides what leaves a control plane, and the +gateway's `otelGateway.metricAllowlist` re-checks the same set on arrival as a +safety net. A metric allowed on only one side never reaches storage. + +Entries are regular expressions matched against the metric name. Histograms +expand into `_bucket`, `_sum`, and `_count` series, so match them with a +trailing `.*`. + +1. Add the metric to the gateway allowlist. Repeat the default entries, since + Helm replaces lists rather than merging them: + + ```yaml + hub-core: + otelGateway: + metricAllowlist: + - "controller_runtime_reconcile_total" + - "controller_runtime_reconcile_errors_total" + - "controller_runtime_reconcile_time_seconds.*" + - "upjet_resource_external_api_calls_total" + - "function_run_function_seconds.*" + - "kube_customresource_.*" + - "my_provider_custom_metric.*" + ``` + +2. Add the same entry to `collector.metricAllowlist` on every connector that + should send it, listing the defaults again for the same reason. + +3. Upgrade both releases. + +:::warning +Every label combination on an allowed metric becomes a stored time series, on +every control plane in the fleet. Check that the metric you're adding doesn't +carry per-resource or per-request labels before you add it. +::: + +## Troubleshooting + +| Symptom | Fix | +| --- | --- | +| `404` on the metrics API group | Enable the `Metrics` gate (`hub-core.api.featureFlags.gates.Metrics=true`). | +| `hub-core` fails to start after enabling the gate | The gate needs `hub-core.otelGateway.enabled=true`. `hub-core` exits when the gate is on and the gateway URLs are unset. | +| Chart fails to render with a missing-backend error | Set `hub-core.otelGateway.metrics.backend` to `prometheus`, `gmp`, or `amp`. | +| `403` on every query | The caller has no authorized control planes. Grant access to a realm or a control plane. See [RBAC](rbac.md). | +| Queries succeed but return no series | Confirm `collector.enabled=true` on the connector, that the target Pods carry `prometheus.io/scrape: "true"`, and that their namespace is in `collector.scrapeNamespaces`. | +| A specific metric never arrives | Add it to both `collector.metricAllowlist` and `otelGateway.metricAllowlist`. | +| Writes work, queries fail on Amazon Managed Prometheus | Annotate `api.serviceAccount` with an IRSA role granting `aps:QueryMetrics`. The gateway's role only covers writes. | +| Queries fail on Google Managed Prometheus | `queryURL` must point at a Prometheus-compatible read endpoint. Google Managed Prometheus doesn't serve one directly, so deploy the [GMP frontend proxy][gmp-frontend] and use its address. | + +## See also + +- [Metrics](../products/insights/metrics/overview.md) +- [Feature flags](../reference/feature-flags.md) +- [Connect a control plane](connect-control-plane.md) +- [Connect a space](connect-space.md) + +[gmp-frontend]: https://cloud.google.com/stackdriver/docs/managed-prometheus/query#promql-ui +[rsm]: https://github.com/kubernetes-sigs/resource-state-metrics diff --git a/hub-docs/howtos/observability.md b/hub-docs/howtos/observability.md new file mode 100644 index 000000000..28587c7b6 --- /dev/null +++ b/hub-docs/howtos/observability.md @@ -0,0 +1,193 @@ +--- +title: Observability +sidebar_position: 16 +description: Collect hub's service metrics, install the hub-observability chart, and export them to your monitoring backend. +--- + +Hub's components emit their own Prometheus metrics: request rates and latency, Go +runtime health, and hub-specific counters for ingestion, queries, and internal +work. This page covers what hub exposes, a turnkey chart to view it, and how to +wire it into your own monitoring backend for production. + +:::note[This page isn't the Metrics pipeline] +This page is about the health of the **hub software itself**. It's separate from +the [Metrics pipeline](metrics.md), which collects **control-plane** metrics from +connected control planes and serves them through the Metrics Query API. The two +are independent: you can run either, both, or neither, and they never share +storage. +::: + +## What the hub exposes today + +`hub-core` serves a Prometheus metrics endpoint on port `8085`. It carries two +kinds of metrics: + +- **Standard framework metrics** its runtime emits automatically. The + [OpenTelemetry HTTP server metrics][otel-http-metrics] for every API route (the + `RED` signals: request rate, errors, and latency) and the usual Go runtime + metrics (`go_*`: `goroutines`, heap, and GC). Consult those conventions for the + full field list. +- **Hub instruments** (prefixed `hub_core_`) for the ingest and query paths and + the internal subsystems, catalogued below. + +All hub metric names below are the Prometheus-exposition form. Counters end in +`_total`; histograms expose `_bucket`, `_sum`, and `_count` series. + +[otel-http-metrics]: https://opentelemetry.io/docs/specs/semconv/http/http-metrics/ + +### Resource ingestion + + + +| Metric | Type | Key labels | Measures | +| --- | --- | --- | --- | +| `hub_core_ingest_resource_total` | counter | `control_plane`, `realm`, `op` | Committed resource ingests. The foundation volume metric. | +| `hub_core_ingest_resource_by_type_total` | counter | `group`, `kind`, `crossplane_type`, `op` | The same ingests broken down by resource type. | +| `hub_core_ingest_rejected_total` | counter | `reason` | Ingest requests rejected before commit. | +| `hub_core_ingest_stale_write_dropped_total` | counter | `control_plane`, `realm` | Upserts dropped because the incoming resource version wasn't newer than the stored one. Spikes indicate out-of-order delivery or a connector resync storm. | +| `hub_core_ingest_event_lag_seconds` | histogram | - | Lag between event generation at the source and receipt by hub. | + + + +### Metrics ingestion + +The ingest side of the Metrics pipeline, measured as hub's own operational +counters. + + +| Metric | Type | Key labels | Measures | +| --- | --- | --- | --- | +| `hub_core_metrics_ingest_forward_total` | counter | `control_plane`, `http_response_status_code` | OTLP metrics forwarded to the gateway. | +| `hub_core_metrics_ingest_rejected_total` | counter | `reason` | Ingest requests rejected before forwarding. | +| `hub_core_metrics_ingest_body_bytes` | histogram | - | Size of accepted OTLP request bodies. | + + +### Query APIs + +| Metric | Type | Key labels | Measures | +| --- | --- | --- | --- | +| `hub_core_resource_query_view_total` | counter | `view` | Resource list queries by projection view (full vs. summary). | +| `hub_core_resource_query_result_count` | histogram | `view` | Result count returned per list query. | +| `hub_core_metrics_query_total` | counter | `outcome` | Metric query requests by terminal outcome. | +| `hub_core_metrics_query_duration_seconds` | histogram | `status` | Prometheus backend range-query latency. | +| `hub_core_metrics_query_result_series` | histogram | - | Series returned by a range query. | +| `hub_core_metrics_query_points_estimate` | histogram | - | Estimated sample points a query materializes .`(end − start) / step`. An expensive-query detector. | +| `hub_core_metrics_query_span_seconds` | histogram | - | Requested query time-range width. | +| `hub_core_metrics_query_scope_rejections_total` | counter | `reason` | PromQL scope-injection rejections. Attempted tenant-boundary violations. | +| `hub_core_metrics_query_scope_size` | histogram | - | Authorized control-plane matchers injected into a scoped query. | + +### Internals + +Hub extracts relationship edges (owner references, Crossplane composite → claim → +managed-resource links) at ingest. When an edge points at a resource hub hasn't +ingested yet, hub holds the edge in a backlog, and a background sweeper promotes +it once the target arrives. + +| Metric | Type | Measures | +| --- | --- | --- | +| `hub_core_pending_edges_count` | gauge | Edges waiting for their target resource. The backlog. A steady rise means many references point at resources not yet ingested. | +| `hub_core_edge_sweeper_promoted_total` | counter | Pending edges resolved into real edges. | +| `hub_core_edge_sweeper_cycle_capped_total` | counter | Sweeps that hit the per-cycle batch cap with a backlog remaining. The sweeper can't clear the backlog at its current rate. | + +## Start with the hub-observability chart + +The quickest way to see these metrics is the `hub-observability` chart. It +installs a single [`grafana/otel-lgtm`](https://github.com/grafana/docker-otel-lgtm) +pod: +- an OpenTelemetry Collector +- Prometheus +- Grafana + + +The pod scrapes `hub-core:8085` into its own Prometheus. It comes with three +preloaded dashboards: **Hub Service Health**, **Hub Ingest**, and **Hub Query**. + + +The chart discovers hub pods in its **own release namespace**, so install it in +the same namespace as the hub: + +```bash +helm install hub-observability oci://xpkg.upbound.io/upbound/hub-observability \ + --namespace hub +kubectl -n hub port-forward svc/hub-observability-hub-observability 3000:3000 +# → http://localhost:3000 +``` + +To run it in a separate namespace instead, point it at the namespace where hub +runs with `scrape.namespace`: + +```bash +helm install hub-observability oci://xpkg.upbound.io/upbound/hub-observability \ + --namespace observability --create-namespace \ + --set scrape.namespace=hub +``` + +The chart is **demo and evaluation-grade**. It runs as one all-in-one pod with a +single PVC on a single node. Use it to explore the metrics and dashboards. Back +production alerting with your own monitoring stack instead. + + +## Export to your own backend + +For production, scrape `hub-core:8085` with your own agent and send the metrics +to the **observability backend of your choice**. + +The pipeline below is **only an example**. An OpenTelemetry Collector that +scrapes the hub pods and remote-writes to a Prometheus-compatible endpoint. Swap +the exporter for one that matches your backend (OTLP, Google Cloud, Datadog, and +so on): + +```yaml +receivers: + prometheus: + config: + scrape_configs: + - job_name: hub-core + kubernetes_sd_configs: + - role: pod + namespaces: + names: [hub] + relabel_configs: + - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name] + action: keep + regex: hub-core + - source_labels: [__meta_kubernetes_pod_ip] + target_label: __address__ + # $$ escapes the Collector's env-var expansion so the relabel + # engine receives a literal ${1} capture group. + replacement: $${1}:8085 +exporters: + prometheusremotewrite: + endpoint: https:///api/v1/write +service: + pipelines: + metrics: + receivers: [prometheus] + exporters: [prometheusremotewrite] +``` + +**Hub Service Health**, **Hub Ingest**, and **Hub Query** are plain Grafana JSON +built on PromQL. If your backend serves the Prometheus query API, import them +into your own Grafana for the same views. + +## Roadmap + +Instrumentation today covers the ingest and query paths. Planned additions, +subject to change: + +- **Authentication and tokens:** OIDC login, token exchange, and device-flow + funnels, currently visible only in logs. +- **Authorization:** the per-request filter cost that dominates list latency for + large organizations, and denial breakdowns. +- **Database:** connection-pool saturation and per-store query latency. +- **Fail-closed alerts:** cheap error counters for feature-flag evaluation and + authorization role checks, so a silent dependency outage becomes an alert. +- **Distributed tracing:** connector ↔ API request-path traces through the same + collector, with an opt-in trace backend in the chart. + +## Next step + +If you haven't yet, work through [Production hardening](production-overview.md) for +sizing, high availability, autoscaling, RBAC, and upgrades. +Once you've prepared your hub install for production, come back and configure +watching for your hardened install. diff --git a/hub-docs/howtos/oidc-configuration.md b/hub-docs/howtos/oidc-configuration.md new file mode 100644 index 000000000..7a262b968 --- /dev/null +++ b/hub-docs/howtos/oidc-configuration.md @@ -0,0 +1,323 @@ +--- +title: OIDC configuration +sidebar_position: 3 +description: Configure Hub against an external OIDC provider. +--- + +Hub delegates all human authentication to an external OIDC provider. The +provider issues ID tokens to users logging in to the Hub UI. Hub trusts those +tokens and reads identity and group claims to drive authorization. + +## What Hub requires from your OIDC provider + +Any [OIDC-compliant][oidc-compliant] provider that meets the +following criteria works with Hub: + +- **OIDC discovery.** The provider publishes a + `.well-known/openid-configuration` document at a stable issuer URL. Hub + fetches this document at startup to learn the authorization, token, and JWKS + endpoints. +- **Authorization code flow.** Hub uses the redirect-based authorization code + flow for browser sign-in. You register Hub as a client (sometimes called an + "application" or "app registration") and receive a client ID and client + secret. +- **Email claim.** The ID token must include an `email` claim and an + `email_verified` claim. Hub uses the email as the canonical username and + rejects logins where `email_verified` isn't `true`. +- **Group claim.** The ID token must include a claim that lists the user's group + memberships. The claim name is configurable through `groupsClaim`. The default + is `groups`. Group values are what you bind to Hub roles to grant privileges + within the system. +- **Redirect URI.** The provider must accept Hub's callback URL as a registered + redirect URI. The callback is always `/oidc/callback`, where + `` is the public base URL of `hub-core` (set through + `hub-core.api.externalURL`). + + +:::note +If your provider emits groups under a non-standard claim name, see +[Provider-specific configuration](#provider-specific-configuration) for the +Hub-side deviation. Entra ID emits `groups` only when explicitly configured, and +Google Workspace doesn't emit groups in the ID token at all. +::: + +## Choosing how to configure the provider + +`sampleEmailBasedOIDCConfig` is a convenience layer over the `IdentityProvider` +resource. It generates a single provider, uses email as the username, and reads +groups from one claim. Most deployments need nothing else, and the rest of this +page assumes it. + +Write the `IdentityProvider` yourself when you need any of: + +- **More than one provider.** The sample block generates a single one. +- **A username that isn't the email claim**, or a CEL expression that builds it. +- **Directory search.** `spec.directory` backs the groups and users APIs, so + administrators can search the provider's directory instead of typing each + group name verbatim. The sample block omits it. +- **Validation beyond `email_verified` and `allowedDomain`**, through + `claimValidationRules` or `userValidationRules`. +- **Issuer plumbing.** A private CA bundle, an in-cluster `backendIssuerURL`, + inline JWKS, or more than one audience. + +Supply your own provider in one of two ways: + +- **`hub-core.bootstrap.files`.** Declarative, and travels with the Helm + release. Hub applies the bootstrap directory at startup and again every five + minutes, so these files stay the source of truth: each pass reverts changes + anyone makes to the same resource through the API. Name your file + `oidc-idp.yaml` to replace the generated one, or use any other name to add a + provider alongside it. +- **The `identityproviders` endpoints.** Change providers at runtime without a + redeploy. Use this only for providers that no bootstrap file defines, since + the next pass over the bootstrap directory overwrites those. See + [Replacing the browser-login provider](#replacing-the-browser-login-provider) + for the lockout risk this path carries. + +One field to decide up front either way: `userInfoPrefix` is immutable. Changing +it later means deleting and recreating the provider, which orphans every role +binding that references the old prefix. + +## Setup order + +Configure OIDC in three stages, in this order. Skipping ahead leaves you +debugging across systems that can't yet see each other. + +### 1. Provider-side + +Done in your OIDC provider's console or API, before touching Hub. + +- Create the users and groups you want to grant access to. At minimum, create + the group you plan to bind to the Hub `org-admin` role. +- Configure the provider to emit group memberships in the ID token under a claim + you control. Note the claim name. +- Register Hub as a client application. Record the client ID and client secret. +- Configure the redirect URI as `/oidc/callback`. You must know the + public hostname of `hub-core` before this step. +- Look up the well-known discovery URL (the issuer URL) for the provider. This + is the value you give to Hub. + +### 2. Hub-side + +Record the values below once your provider is configured. You don't write any +`values.yaml` on this page. [The install guide][install] owns that file, and you +paste these values into it in step 4. + + +- `hub-core.api.externalURL`. The public base URL of `hub-core`. The redirect URI + you registered in stage 1 must match this exactly. +- The OIDC values under `hub-core.api.sampleEmailBasedOIDCConfig`: + - `providerName`. A short identifier used as a prefix on usernames and group + names (such as `entra`, `google`, `cognito`). Defaults to `oidc`. + - `issuerURL`. The issuer URL from stage 1. + - `clientID`. The client ID from stage 1. + - `clientSecret`. The client secret from stage 1. Don't commit this to + `values.yaml`. The install guide passes it on the `helm install` command + line, and for production you supply it through your secret management + workflow. + - `allowedDomain`. Optional. If set, Hub rejects logins whose email doesn't + end in `@`. + - `groupsClaim`. The token claim carrying group membership. Defaults to + `groups`. Set it to your provider's claim name if it differs (Cognito uses + `cognito:groups`), or to `""` to omit the mapping, which leaves only + per-user email bindings working. +- The first administrators, for `hub-core.bootstrap.admins`. Each entry is a + `kind` and `name` pair, where `kind` is `Group` or `User` and `name` carries + the `:` prefix. Hub renders both an `OrganizationRoleBinding` + bound to `org-admin` and a `RealmRoleBinding` bound to `realm-admin` on the + `default` realm from this one list: + + ```yaml + hub-core: + bootstrap: + admins: + - kind: Group + name: "oidc:platform-admins" + - kind: User + name: "oidc:alice@example.com" + ``` + + Group names come from the `groupsClaim` value, prefixed with + `:`. User names are the user's email, prefixed the same way. + With Entra ID emitting group object IDs, the subject is + `:`. + +:::warning +Without at least one entry in `hub-core.bootstrap.admins`, the first user to log +in has no permissions and no way to grant themselves any. Set it before the +first install. +::: + + + +### 3. End-user login + +Done from the browser, after you install the chart. [The install +guide][install] covers this step in place, once the values above are in +`values.yaml`. + +- Navigate to the Hub UI at the URL you configured. +- Sign in. The provider authenticates you and redirects back to Hub. +- Confirm Hub recognizes your identity and group memberships. A user in + `hub-core.bootstrap.admins` sees the full UI. A user without any bound group + sees an empty workspace. + +## Provider-specific configuration + +Register the Hub application in your provider following that provider's own +documentation. Hub needs no special setup beyond a confidential +authorization-code client with the `openid`, `email`, and `profile` scopes and +the redirect URI from [Setup Order](#setup-order). + +Once the application exists, the only Hub-side settings that differ between +providers are the `issuerURL` and how group memberships reach the ID token. +Every other value from stage 2 stays the same. The deviations for each provider +are below. The YAML fragments in this section show only the keys that deviate. +Merge them into the OIDC block in step 4 of [the install guide][install]. + +### Standards-compliant providers + +Providers that emit a `groups` claim out of the box (Keycloak, Auth0, Okta, +Dex, and similar) need no deviation. + +- `issuerURL`: the `issuer` value published in the provider's + `.well-known/openid-configuration`. +- Groups: emitted by default under the claim name `groups`. No override needed. + +The values you recorded in stage 2 need no changes. + + +### Amazon Cognito + + +See the [Amazon Cognito Developer Guide][amazon-cognito-developer-guide] +for user pool and app client setup. + +- `issuerURL`: `https://cognito-idp..amazonaws.com/`. +- Groups: membership is published under `cognito:groups`, not `groups`. Point + `groupsClaim` at it: + + + ```yaml + hub-core: + api: + sampleEmailBasedOIDCConfig: + providerName: cognito + groupsClaim: "cognito:groups" + ``` + + With `providerName: cognito`, a Cognito group `admin` becomes the Hub subject + `cognito:admin`. + + +### Microsoft Entra ID + + +See [Register an application with Microsoft Entra ID][register-an-application-with-microsoft-entra-id]. + +- `issuerURL`: `https://login.microsoftonline.com//v2.0`. The `/v2.0` + suffix is required, since Hub expects the v2.0 token format. +- Groups: the `groups` claim is omitted unless you add a groups optional + claim under the app registration's **Token configuration**. Emit **Group ID**, + so groups arrive as object IDs (UUIDs); bind role bindings to + `:`. Once emitted, the claim is named `groups`, + so no claim-mapping override is needed. Users belonging to more than ~150 + groups exceed the token overage limit and receive no `groups` claim at all. + Prefer narrow security groups dedicated to Hub. + + + +### Google Workspace + + +See [Setting up OAuth 2.0][setting-up-oauth-2-0] for +OAuth client setup. + +- `issuerURL`: `https://accounts.google.com` (fixed). +- Groups: group membership from Workspace never appears in the ID token. + Choose one of: + - **Bind to users.** Set `groupsClaim: ""` and list each administrator as a + `User` in `hub-core.bootstrap.admins`, using their email with the + `:` prefix (`google:alice@example.com`). + - **Custom claim.** Inject a groups claim upstream (Cloud Identity custom + attribute or an identity broker), then point `groupsClaim` at that claim + name. + +## Replacing the browser-login provider + +A single provider drives the browser redirect login at any time. A unique index +in the database enforces it: Hub rejects `spec.redirect.browserLogin` on a +second provider while the first holds it, and refuses to delete the provider +that holds it. + +:::note +Moving browser login from one provider to another in a single step is a roadmap +item for a future release. Until then, follow the sequence below. +::: + +Two details make this more than a flag swap: + +- `userInfoPrefix` is immutable and prefixes every username and group value, so + role bindings written against the old provider (`old:admins`) don't match the + new one (`new:admins`). +- No provider drives browser login between clearing the flag and setting it, so + new sign-ins fail for that window. Sessions already issued keep working until + you delete the old provider. + +To migrate: + +1. Create the new provider with `browserLogin` unset. Choose a `providerName` + where neither prefix starts with the other. `okta` and `entra` coexist; + `oidc` and `oidc2` collide. +2. Duplicate your role bindings under the new prefix. An + `OrganizationRoleBinding` on `old:admins` needs a counterpart on + `new:admins`. Leave the old bindings alone for now. +3. Clear `browserLogin` on the old provider. +4. Set `browserLogin` on the new provider. Browser login works again here. +5. Sign in through the new provider and confirm your group memberships resolve. +6. Delete the old provider, then the role bindings that referenced its prefix. + +:::warning +Apply step 3 and step 4 as separate changes, in that order. Hub walks the +bootstrap directory in filename order and stops at the first error, so a single +upgrade carrying both edits can reach the new provider first and reject it while +the old one still holds the flag. +::: + +A provider that exists only in the database has no safety net. Changing its +`issuerURL` locks out everyone it authenticates, and nothing restores the old +value. Role bindings survive by name, so recreating the provider with the same +`userInfoPrefix` restores access, but that takes a working login. Define +anything you depend on for administrator access in `bootstrap.files`, where the +five-minute reconcile repairs it. + +## Values to record + +Carry these into step 4 of [the install guide][install]: + +| Value | Where it came from | +| --- | --- | +| `externalURL` | The public base URL of `hub-core` | +| `providerName` | Your choice of prefix, defaulting to `oidc` | +| `issuerURL` | Stage 1, adjusted per your provider above | +| `clientID` | Stage 1 | +| Client secret | Stage 1. Passed on the command line, not committed | +| `allowedDomain` | Optional email-domain restriction | +| `groupsClaim` | Only if your provider deviates from `groups` | +| Admin groups or users | Stage 2, prefixed with `:` | + +## Next step + +With your provider configured, set up the database Hub stores its state in: + +- [Databases][overview]. Postgres requirements and per-provider + provisioning. + +Once your database is ready, [install Hub][install]. + +[amazon-cognito-developer-guide]: https://docs.aws.amazon.com/cognito/latest/developerguide/ +[install]: /hub/howtos/install +[oidc-compliant]: https://openid.net/connect/ +[overview]: /hub/howtos/databases/overview +[register-an-application-with-microsoft-entra-id]: https://learn.microsoft.com/entra/identity-platform/quickstart-register-app +[setting-up-oauth-2-0]: https://support.google.com/cloud/answer/6158849 diff --git a/hub-docs/howtos/prerequisites.md b/hub-docs/howtos/prerequisites.md new file mode 100644 index 000000000..bb415f211 --- /dev/null +++ b/hub-docs/howtos/prerequisites.md @@ -0,0 +1,149 @@ +--- +title: Prerequisites +sidebar_position: 1 +description: All requirements for installing and maintaining self-hosted Hub. +--- + +Before you start the process for installing a self-hosted version of the Hub, +this page helps you understand each of the necessary requirements. + + +This pre-flight checklist is required before you move to the [installation] +step. + + +## Cluster requirements + +First, you need a Kubernetes cluster. + +The cluster must: + +- Run Kubernetes 1.29 or later. The chart relies on stable Gateway API + CRDs and on Pod security primitives available in 1.29+. Earlier versions are + not tested and may render templates that the API server rejects. +- Allow you `cluster-admin` access. The install creates a Namespace, ServiceAccounts, + RBAC, Deployments, Services, Secrets, and (optionally) Gateway API resources. + You can scope the install RBAC down after first install. See the chart's + generated manifests for the bound roles. + +:::note +Hub doesn't require any cluster-scoped operators beyond what you choose to use +for ingress, TLS, or Postgres. The chart installs into a single Namespace. +::: + + +## Ingress and TLS + + +Hub serves the API and UI services over HTTPS on operator-provided hostnames. +You can use any conformant controller for outside traffic to reach them. + + +The chart can render Kubernetes Gateway API resources for you when you set +`global.gateway.enabled=true`. In that mode you point it at a pre-existing +`Gateway` (recommended) or have the chart create one. The chart never installs +the Gateway controller itself, so you must install that separately. + + +You need: + + + +- **A Gateway controller or Ingress controller already installed in the + cluster.** Any conformant option works (Envoy Gateway, Istio, Cilium, Contour, + NGINX Ingress, and similar). The chart's Gateway API templates are agnostic, + targeting whichever controller you nominate via + `global.gateway.gatewayClassName` or via the `parentRef` of an existing + `Gateway`. +- **A real TLS certificate** covering the hostnames you publish (see DNS below). + [cert-manager][cert-manager] configured against an ACME issuer + (Let's Encrypt, ZeroSSL, an internal ACME server) is the common pattern. You + can also terminate TLS upstream (at an AWS load balancer using ACM, or at a + cloud provider's Gateway) and leave + `global.gateway.listeners.https.tls.certificateRefs` empty. +- **A LoadBalancer or external Service IP** that your DNS records can point at. + The chart doesn't manage external IP allocation. + + + + +For Gateway API documentation, see the upstream [Gateway API +project][gateway-api-project]. + +## DNS + + +Hub is designed to span multiple subdomains, but can optionally be served from a +single domain. The defaults the chart composes are `.` +for each. You control the apex domain, and the subdomain prefixes are +configurable. + + +| Default Hostname | Serves | +|--------------------|--------| +| `api.` | `hub-core` HTTP API | +| `ui.` | `hub-webui` browser UI | + +Substitute `` with the apex domain you control (such as +`hub.example.com`, giving you `api.hub.example.com` and `ui.hub.example.com`). +The subdomain prefixes default to `api` and `ui`. Override them per service via +`global.gateway.subdomains.*` if your conventions differ. + +For each hostname you publish, you need: + +- A DNS A or CNAME record pointing at the external IP, hostname, or load + balancer fronting your Gateway / Ingress. +- TLS certificate coverage. A single wildcard certificate (`*.`) is + the simplest. Per-host certificates also work. + + +The hostname you choose for `hub-core` is the value you set as +`hub-core.api.externalURL` at install time. `hub-core` uses it to construct the +OIDC callback URI it registers with your provider. The value must be +reachable from end-user browsers exactly as configured. + + + +## External PostgreSQL + + +Hub stores all resource state in PostgreSQL. Provision the database before +install. The chart only consumes a connection for an existing PostgreSQL +instance, and will not deploy PostgreSQL. + +You need a PostgreSQL 18 (or later) instance reachable from the cluster, with a +dedicated database and a role Hub can use. [The databases +overview][overview] and the provider sub-guides it links to cover +the version, required extensions, authentication modes, TLS settings, and +per-cloud provisioning steps. That page is the source of truth. + +## External OIDC Provider + +Hub doesn't include its own identity provider in the self-hosted path. +Configure Hub against an OIDC-compliant provider you already operate or +subscribe to. + +The provider needs OIDC discovery, plus `email` and group claims. It also has to +accept a redirect URI Hub computes from your `hub-core` hostname. Common +providers (Amazon Cognito, Microsoft Entra ID, Google Workspace, Okta, Auth0, +Keycloak) all qualify. The full contract, the staged setup order, and the +per-provider walkthroughs are on [the OIDC overview][oidc-configuration]. That +page is the source of truth. + +## Next step + +With every requirement above understood, set up each dependency before you +install. Start with your identity provider: + +- [OIDC configuration][oidc-configuration]. The provider contract and + per-provider setup. +- [Databases][overview]. Postgres requirements and provisioning. + +Once your provider and database are ready, [install Hub][installation]. + +[cert-manager]: https://cert-manager.io/ +[cert-manager-docs]: https://cert-manager.io/docs/ +[gateway-api-project]: https://gateway-api.sigs.k8s.io/ +[installation]: /hub/howtos/install +[oidc-configuration]: /hub/howtos/oidc-configuration +[overview]: /hub/howtos/databases/overview diff --git a/hub-docs/howtos/production-overview.md b/hub-docs/howtos/production-overview.md new file mode 100644 index 000000000..352bf2d81 --- /dev/null +++ b/hub-docs/howtos/production-overview.md @@ -0,0 +1,86 @@ +--- +title: Production hardening +sidebar_position: 10 +description: Sizing, high availability, autoscaling, RBAC, and upgrades for production Hub. +--- + +You have a working self-hosted Hub. This section covers how to configure it with +the necessary scaling and security configuration for production traffic. + +The pages in this section aren't a linear install path. They're independent +topics you apply on top of the [self-hosted install][install]. +Work through them in any order, but treat the five hardening topics as required +reading before Hub serves production traffic, then set up observability to watch +the result. + +## Sizing + +Pick replica counts, resource requests, and a Postgres tier that match your +workload before you turn on redundancy or autoscaling. Both depend on a sensible +baseline. Your connector count, the resources tracked per +connector, and the query rate against `hub-core` drive sizing. The sizing page defines small, +medium, and large tiers with concrete numbers for each and a short decision tree +for picking a starting tier. See [Sizing][sizing]. + +## High availability + +Once you have a tier, run `hub-core` and `hub-webui` with more than one replica +and spread them across nodes with anti-affinity. Bound voluntary disruptions +with a PodDisruptionBudget. The HA page walks the Helm values for each +(replicas, PDBs, topology spread) and verifies that a node drain no longer takes +the UI offline. See [High availability][high-availability]. + +## Autoscaling + +Static replica counts cover steady-state load. Traffic can vary: daily +peaks, ad-hoc query bursts, or growth that outruns your sizing tier. For that, enable the +HorizontalPodAutoscaler for `hub-core` so Pod count tracks CPU. See +[Autoscaling][autoscaling]. + + +## RBAC + +The demo bootstraps an organization-level admin binding for a Keycloak group, +which lets you log in immediately. For production, retarget that binding at a +group from your real OIDC provider and add ControlPlane-scoped bindings for the +rest of your users. The RBAC page documents Hub's role model alongside the +mechanics of reading group claims from an OIDC token. It also has the YAML for binding +those groups to organization-level and ControlPlane-level roles. See +[RBAC][rbac]. + +## Upgrades + +Hub bundles `hub-core`, `hub-connector`, and `hub-webui` in a single chart on +one release train, and schema migrations run automatically as part of `helm +upgrade`.That means upgrades are quick to perform and carry real risk if you get +them wrong. Migrations are forward-only, so a chart rollback doesn't undo a +schema change. + +The upgrades page covers how to read release notes, stage +upgrades in a non-production install, run the upgrade, and the boundary on +rollbacks. See [Upgrades][upgrades]. + +## Observability + + + +Once you've hardened hub and it serves traffic, watch its health. On +`hub-core:8085`, hub emits framework metrics (HTTP RED and Go runtime) alongside +its own ingest and query instruments. The observability page catalogs those +metrics. It also covers a turnkey dashboards chart for viewing them and how to +export them to your own monitoring backend. See [Observability][observability]. + + + +## Next step + +Start with [Sizing][sizing]. The other four hardening topics reference the tier +it produces. + +[autoscaling]: /hub/howtos/autoscaling +[high-availability]: /hub/howtos/high-availability +[install]: /hub/howtos/install +[observability]: /hub/howtos/observability +[rbac]: /hub/howtos/rbac +[sizing]: /hub/howtos/sizing +[upgrades]: /hub/howtos/upgrades diff --git a/hub-docs/howtos/rbac.md b/hub-docs/howtos/rbac.md new file mode 100644 index 000000000..d677030d4 --- /dev/null +++ b/hub-docs/howtos/rbac.md @@ -0,0 +1,334 @@ +--- +title: RBAC and OIDC group mapping +sidebar_position: 14 +description: Bind OIDC groups to Hub roles across the organization, realm, and control-plane tiers. +--- + +## Tenancy boundaries + +Hub authorizes requests across three nested tiers. Each tier owns its own grant +mechanism. Organization grants don't reach into realms, but realm grants do +reach into the control planes a realm contains. + +**Organization**. The Hub installation as a whole. Everything served from a +single `hub-core` is one organization. Organization-scoped state includes the set +of realms, the identity providers, and the organization-level role bindings +themselves. + +**Realm**. A logical grouping of control planes inside an organization. Most Hub +API resources are realm-scoped (control plane registrations, realm-level role +bindings, and other per-tenant configuration all live in a realm). An +organization can contain many realms. A common pattern is one realm per team or +environment. + + +**Control plane**. An individual Kubernetes cluster (commonly a Crossplane +control plane) registered into a realm. The resources *inside* a control plane +(Crossplane Compositions, claims, providers, and any other custom resources) are +governed by that cluster's own Kubernetes RBAC. Hub owns no binding resource at +this tier, though a realm role can still grant read access across it. + + +:::note +An organization administrator has no automatic access inside any realm. Bind +them with a `RealmRoleBinding` in every realm they need to operate in. + +Realm grants do reach downward. A `realm-admin` reads every resource in every +control plane in the realm, whatever that control plane's own RBAC says. A +`realm-editor` or `realm-viewer` reads a control plane's contents only where +that control plane's RBAC grants it. Writing to resources inside a control +plane always comes from the control plane's RBAC, whatever the realm role. +::: + +## Hub's role-binding resources + +Hub provides one binding resource per tier it owns. The control-plane tier has +no Hub binding resource of its own: access there comes from a realm role, from +the control plane's own RBAC, or from both. + +| Tier | Hub binding | Scope | Built-in roles | +|------|-------------|-------|----------------| +| Organization | `OrganizationRoleBinding` (ORB) | Cluster-scoped (Hub-wide) | `org-admin` | +| Realm | `RealmRoleBinding` (RRB) | Namespaced in the realm | `realm-admin`, `realm-editor`, `realm-viewer` | +| Control plane | None | Realm roles plus the control plane's own RBAC | None | + +`OrganizationRoleBinding` and `RealmRoleBinding` live in the +`authorization.hub.upbound.io/v1beta1` API group. They accept the same shape of +`subjects` list. Each subject has a `kind` of `User` or `Group` and a `name` +matched against the identity Hub derives from the OIDC token. + +## Group claim and OIDC mapping + +When a user authenticates, Hub validates the OIDC token against the configured +`IdentityProvider` and constructs the identity used for authorization from the +token's claims. + +Three `IdentityProvider` settings govern the identity Hub sees: + +- `spec.validation.claimMappings.username.claim` selects which claim becomes the + username. It defaults to `sub`, but `sub` is an opaque provider-side + identifier on most providers, so bind-by-user is only readable if you set this + to `email`. Every example on this page assumes `email`. +- `spec.validation.claimMappings.groups.claim` selects which claim in the token + carries the user's group membership. Most providers expose a `groups` claim + once you configure the corresponding scope, app role, or group claim mapping + on the provider side. +- Hub adds `spec.validation.userInfoPrefix` to every value it reads from + the user and group claims. The prefix prevents collisions between providers + and makes the source of an identity clear in audit logs and bindings. + +Each mapping takes either a `claim` or a CEL `expression`, never both. Use +`expression` when a single claim isn't enough, such as building a username from +two claims. + +A provider that maps email to the username and reads groups from `groups`: + +```yaml +apiVersion: authentication.hub.upbound.io/v1beta1 +kind: IdentityProvider +metadata: + name: corp +spec: + redirect: + browserLogin: true + clientSecret: "" + scopes: + - openid + - email + - profile + validation: + userInfoPrefix: "corp:" + issuer: + url: https://login.example.com/ + audiences: + - "" + claimMappings: + username: + claim: email + groups: + claim: groups + claimValidationRules: + - expression: "claims.email_verified == true" + message: "email must be verified" +``` + +The `userInfoPrefix` is conventionally `:`, which is what the +Helm `sampleEmailBasedOIDCConfig` block generates. It's immutable once set. + +The effective group identity Hub uses is ``. If +`userInfoPrefix` is `corp:` and the OIDC token carries a `groups` claim of +`["platform-admins", "developers"]`, Hub sees the user as a member of groups +`corp:platform-admins` and `corp:developers`. Usernames get the same treatment, +so an `email` claim of `alice@example.com` becomes `corp:alice@example.com`. +Role bindings must use the prefixed form in their `subjects[].name` fields. + +Check the identity Hub derived for you before writing bindings. Hub serves the +`SelfSubjectReview` endpoint, so `kubectl auth whoami` reports it directly: + +```bash +kubectl --context hub auth whoami +``` + +``` +ATTRIBUTE VALUE +Username corp:alice@example.com +Groups [corp:platform-admins corp:developers system:authenticated] +Extra: authentication.hub.upbound.io/email [alice@example.com] +``` + +This shows the mapped and prefixed identity, which is what bindings match, not +the raw claims. Copy the `Username` and `Groups` values straight into +`subjects[].name`. + +If `Groups` holds only `system:authenticated`, Hub found no group membership. +Either `claimMappings.groups` is unset on the `IdentityProvider`, or the claim +it names is missing from the token. Fix the second case on the OIDC provider +side. See [the OIDC overview][oidc-configuration] for the provider-specific +group-claim setup. + +## Configure organization-level access + +Grant a group organization-wide permissions by creating an +`OrganizationRoleBinding`. The resource is cluster-scoped, so it has no +`namespace`. The built-in organization role is `org-admin`. + +Assuming `corp:` is the `userInfoPrefix` from your +`IdentityProvider` and `platform-admins` is the group value your OIDC provider +emits, save this as `org-admin-binding.yaml`: + +```yaml +apiVersion: authorization.hub.upbound.io/v1beta1 +kind: OrganizationRoleBinding +metadata: + name: platform-admins +roleRef: + name: org-admin +subjects: +- kind: Group + name: "corp:platform-admins" +``` + +Apply it against Hub's API: + +```bash +kubectl --context hub apply -f org-admin-binding.yaml +``` + +List multiple subjects on a single binding to grant the same role to several groups: + +```yaml +subjects: +- kind: Group + name: "corp:platform-admins" +- kind: Group + name: "corp:sre-oncall" +``` + +You can also bind individual users directly with `kind: User`. The `name` is the +prefixed username Hub derives from `claimMappings.username.claim` (in the +default configuration this is the email claim), so `name: +"corp:alice@example.com"`. + +:::note +An `org-admin` ORB grants organization-scoped capabilities only, such as +managing realms, identity providers, and Hub-wide settings. It doesn't grant +access to any realm's contents. You must grant the same subject a +`RealmRoleBinding` in every realm they need to operate in. +::: + +## Configure realm-level access + +Most Hub API resources live at the realm level: control plane registrations, +realm-level role bindings, and other per-tenant configuration. + +Create a realm in the Console under **Settings** → **Realms**: + +![Creating a realm in the Console](/img/hub/rbac/realm-create-dialog.png) + +![The realms list in the Console](/img/hub/rbac/realms-list.png) + +Grant a group access to a realm by creating a `RealmRoleBinding` in the realm's +namespace. The namespace name **must match** the realm name. + +Save this as `prod-east-viewer-binding.yaml`: + +```yaml +apiVersion: authorization.hub.upbound.io/v1beta1 +kind: RealmRoleBinding +metadata: + name: viewers + namespace: prod-east +spec: + roleRef: + name: realm-viewer + subjects: + - kind: Group + name: "corp:developers" +``` + +Apply it the same way: + +```bash +kubectl --context hub apply -f prod-east-viewer-binding.yaml +``` + +Pick the role: + +- **`realm-admin`**. Full control of realm-scoped resources, including the + ability to register, update, and remove control planes within the realm. Also + reads every resource inside every control plane in the realm, whatever that + control plane's RBAC says. +- **`realm-editor`**. Read-write access to realm-scoped resources. Can't modify + realm role bindings. Reads resources inside a control plane only where that + control plane's RBAC grants it. +- **`realm-viewer`**. Read-only access to realm-scoped resources. Reads + resources inside a control plane only where that control plane's RBAC grants + it. + +`spec.roleRef.name` must be one of those three values. Hub rejects bindings that +reference any other name. + + +Repeat the binding for each realm a group should access. A single +`RealmRoleBinding` covers exactly one realm. + + +:::note +A subject can hold different roles in different realms, such as `realm-admin` in +`prod-east` and `realm-viewer` in `prod-west`. Create one `RealmRoleBinding` per +realm. +::: + +:::note +What a `RealmRoleBinding` grants *inside* a control plane depends on the role. +A `realm-admin` reads every resource in every control plane in the realm. A +`realm-editor` or `realm-viewer` can see *that* a control plane exists, but +reads its contents only where the control plane's own RBAC grants it. The next +section covers that side. +::: + +## Configure control-plane-level access + +Hub doesn't provide a binding resource for this tier. Each control plane is +itself a Kubernetes cluster with its own RBAC. + +This tier is what a `realm-editor` or `realm-viewer` depends on: the control +plane's RBAC decides what Hub surfaces from inside it, and it's the only way to +grant write access to those resources. A `realm-admin` already reads everything +in the realm, so these steps only widen what they can change. + +This tier works if and only if the control plane authenticates the same +identities Hub does. Point both at the same OIDC provider, so a token Hub +accepts names the same user and groups inside the cluster. Without that, the +subject names in the next step refer to a user the control plane has never heard +of. + +To grant a user access to specific resources inside a control plane: + +1. Declare which Hub identity providers this control plane trusts, on the + `ControlPlane` resource in Hub: + + ```yaml + spec: + identityProviders: + - name: corp + userMappingType: Exact + ``` + + `name` is the `metadata.name` of the Hub `IdentityProvider`. `Exact` maps the + Hub identity onto the control plane's RBAC subjects unchanged, prefix + included, and is currently the only supported mapping. Leaving + `identityProviders` empty trusts every provider with `Exact` mapping, which + is the default. +2. Inside the control plane, create a `Role` or `ClusterRole` granting the + Kubernetes-level permissions you want, such as `get`, `list`, `watch` on a + particular Crossplane API group. +3. Bind that role to the user or group with a `RoleBinding` or + `ClusterRoleBinding`. The subject `name` uses the same prefixed identity Hub + derives from the OIDC token (such as `corp:platform-admins`). + +Hub queries the control plane's authorization API at request time and combines +the answer with the caller's realm role. The two sources are additive: a user +sees a resource if either their realm role or the control plane's RBAC admits +it. A `realm-viewer` who also holds a `ClusterRoleBinding` granting write access +inside one control plane gets read access across the realm and write access in +that one control plane. + +:::warning +A control plane that lists `identityProviders` without the provider that issued +your identity can't see you at all, and its bindings never match. Visibility +then comes from your realm role alone. +::: + +Refer to your control plane's documentation for the RBAC primitives it exposes. +For a Crossplane managed control plane, this is standard Kubernetes RBAC against +the Crossplane resource types. + +## Next step + +- Revisit the OIDC group-claim setup if group identities don't surface as + expected: [OIDC overview][oidc-configuration]. +- Return to the [production hardening overview][production-overview]. + +[oidc-configuration]: /hub/howtos/oidc-configuration +[production-overview]: /hub/howtos/production-overview diff --git a/hub-docs/howtos/sizing.md b/hub-docs/howtos/sizing.md new file mode 100644 index 000000000..b6ecb31d2 --- /dev/null +++ b/hub-docs/howtos/sizing.md @@ -0,0 +1,210 @@ +--- +title: Sizing +sidebar_position: 11 +description: Pick replica counts, resources, and a Postgres tier by total resource count. +--- + +This page helps you pick replica counts, resource requests, and a Postgres tier +based on the total number of resources your Hub installation tracks. + +:::note +Upbound tested this guidance on GKE at roughly 200,000 and 600,000 resources, +against a single Cloud SQL `db-custom-8-32768` instance (8 vCPU, 32 GiB) running +PostgreSQL 18. Neither test point showed memory pressure. Both used small resource +objects, so larger `spec` and `status` bodies raise ingest cost and database size +on top of the counts below. Above 600,000 resources, start at the `large` tier and +contact Upbound. +::: + +## Sizing tiers + +One number picks your tier: the total resources Hub tracks. Each connector syncs +the resources in its target control plane and reports them to `hub-core`, which +persists them in PostgreSQL. Count across all connectors, then read the matching +tier below. + +The tiers describe `hub-core` replica count, `hub-core` per-pod resource requests +and limits, `hub-connector` per-pod resources, and a recommended Postgres tier. +The chart includes empty `resources: {}` for `hub-core` and `hub-connector` by +default. Set the values shown here explicitly via your `values.yaml`. + +Memory requests and limits are identical across all three tiers, which is +deliberate: memory stays flat as the resource count grows for both components. +What moves between tiers is the PostgreSQL instance class, along with the +`hub-core` replica count and CPU request. `hub-core` holds no bulk state, so its +replicas are about availability and absorbing concurrent queries rather than +store size. + +### Small + + +For evaluation installs, internal platform teams, and early production with a +handful of clusters. + + +- **Total resources:** up to 50,000. +- **`hub-core` replicas:** 2, for redundancy across nodes. See [high + availability][high-availability]. +- **`hub-core` resources per pod:** requests `cpu: 250m`, `memory: 512Mi`. Limits + `cpu: 1`, `memory: 1Gi`. +- **`hub-connector` resources per pod:** requests `cpu: 100m`, `memory: 256Mi`. + Limits `cpu: 1`, `memory: 512Mi`. +- **Postgres:** 2 vCPU and 8 GiB. See [PostgreSQL instance + classes](#postgresql-instance-classes) for the equivalent in each cloud. Start + at 20 GiB of SSD storage with autoscaling enabled. No read replica needed. + +```yaml +hub-core: + api: + replicaCount: 2 + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: 1 + memory: 1Gi + +hub-connector: + connector: + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 1 + memory: 512Mi +``` + +### Medium + +For production installs serving a platform organization with a moderate fleet. + +- **Total resources:** up to 200,000. +- **`hub-core` replicas:** 3. +- **`hub-core` resources per pod:** requests `cpu: 500m`, `memory: 512Mi`. Limits + `cpu: 1`, `memory: 1Gi`. +- **`hub-connector` resources per pod:** requests `cpu: 100m`, `memory: 256Mi`. + Limits `cpu: 1`, `memory: 512Mi`. +- **Postgres:** 2 to 4 vCPU and 16 GiB. See [PostgreSQL + instance classes](#postgresql-instance-classes). Start at 20 GiB of SSD storage + with autoscaling enabled. + +```yaml +hub-core: + api: + replicaCount: 3 + resources: + requests: + cpu: 500m + memory: 512Mi + limits: + cpu: 1 + memory: 1Gi + +hub-connector: + connector: + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 1 + memory: 512Mi +``` + +### Large + +For broad fleets and heavy automation traffic. + +- **Total resources:** up to 600,000. +- **`hub-core` replicas:** 5, with the [Horizontal Pod + Autoscaler][autoscaling] enabled to absorb bursts. +- **`hub-core` resources per pod:** requests `cpu: 1`, `memory: 512Mi`. Limits + `cpu: 2`, `memory: 1Gi`. Observed memory stayed well under the limit at this + scale, leaving headroom for query concurrency and garbage collection. +- **`hub-connector` resources per pod:** requests `cpu: 100m`, `memory: 256Mi`. + Limits `cpu: 1`, `memory: 512Mi`. Consider narrowing + `connector.sync.limitToClusterRoles` to the resources you actually query. Each + synced type adds an informer that consumes connector memory. +- **Postgres:** 8 vCPU and 32 GiB. See [PostgreSQL + instance classes](#postgresql-instance-classes). Start at 50 GiB of SSD storage + with autoscaling enabled. Add a read replica if you observe contention between + connector writes and UI reads. + +```yaml +hub-core: + api: + replicaCount: 5 + resources: + requests: + cpu: 1 + memory: 512Mi + limits: + cpu: 2 + memory: 1Gi + autoscaling: + enabled: true + minReplicas: 5 + maxReplicas: 15 + targetCPUUtilizationPercentage: 70 + +hub-connector: + connector: + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 1 + memory: 512Mi +``` + +## PostgreSQL instance classes + +Size the [managed PostgreSQL instance][databases] for connection count and burst +CPU during ingest, not for data volume. Start from the row matching your resource +count: + +| Total resources | vCPU and RAM | AWS RDS | GCP Cloud SQL | Azure Database for PostgreSQL | +|-----------------|--------------|---------|---------------|-------------------------------| +| Up to 50,000 | 2 vCPU, 8 GiB | `db.m6g.large` | `db-custom-2-8192` | `Standard_D2ds_v5` | +| Up to 200,000 | 2 to 4 vCPU, 16 GiB | `db.m6g.xlarge` | `db-custom-4-16384` | `Standard_D4ds_v5` | +| Up to 600,000 | 8 vCPU, 32 GiB | `db.m6g.2xlarge` | `db-custom-8-32768` | `Standard_D8ds_v5` | + +Use the General Purpose tier on Azure. Provider instance names change between +generations, so confirm the current equivalent in your provider's console. + +:::warning +Both test points ran on the single 8 vCPU, 32 GiB instance named above, so the two +smaller rows are interpolations rather than tested configurations. Upbound also +didn't profile how saturated that instance was, so a smaller class may well serve +the same workload. Treat every row as a starting point rather than a requirement, +and watch CPU, connection saturation, and cache hit ratio under your own load. +::: + +## Pick your tier + +Count the resources Hub tracks across every connector: under 50,000 → `small`, +50,000 to 200,000 → `medium`, 200,000 to 600,000 → `large`. Above 600,000, start +at `large` and contact Upbound, because that scale is beyond what Upbound tested. + +Whichever tier you land on is a starting point. If you expect many concurrent UI +users or heavy automation traffic, add `hub-core` replicas or enable the +[Horizontal Pod Autoscaler][autoscaling], neither of which changes the memory +values. Roll out to a non-production install first, watch CPU, memory, and +PostgreSQL connection saturation under realistic load, and adjust before promoting +to production. + +## Next step + +- [Provision your database][databases]. Review the PostgreSQL version, extension, + and authentication requirements for the instance class you picked. +- [Run Hub with redundancy][high-availability]. Set replica counts, pod + disruption budgets, and anti-affinity for the tier you picked. +- [Configure autoscaling][autoscaling]. Enable the Horizontal Pod Autoscaler + for `hub-core` and turn on storage autoscaling on your Postgres tier. + +[autoscaling]: /hub/howtos/autoscaling +[databases]: /hub/howtos/databases/overview +[high-availability]: /hub/howtos/high-availability diff --git a/hub-docs/howtos/upgrades.md b/hub-docs/howtos/upgrades.md new file mode 100644 index 000000000..11afd7e15 --- /dev/null +++ b/hub-docs/howtos/upgrades.md @@ -0,0 +1,156 @@ +--- +title: Upgrades +sidebar_position: 15 +description: Upgrade Hub and understand schema migrations and rollbacks. +--- + +Upgrading a running Hub installation to a newer chart version applies schema +migrations, which constrain how far you can roll back. + +## How releases work + + + +Hub releases as a single train. The `hub` umbrella chart pins matching versions +of all three binaries (`hub-core`, `hub-connector`, and `hub-webui`), and they +are released together at the same chart version. You can install and manage each subchart +separately, see the feature compatibility document to understand +compatibility guarantees between versions. + + +Schema migrations are applied automatically as part of every chart upgrade. The +chart runs the embedded migration tool in a dedicated Job, registered as a Helm +`pre-upgrade` hook. Helm blocks on that Job until it succeeds before rolling +out any new `hub-core` Pods. A single Job applies the schema change once, +rather than every replica racing it during a rolling update. Only after the +migration Job succeeds does Helm proceed to roll the new `hub-core` Pods. + +Migrations are **forward-only**. The migration tool has no down-migrations. Once +a newer chart version has run its migrations against your database, the schema +reflects the newer version. There's no automated path back to the previous +schema. + +## Plan an upgrade + +Before running `helm upgrade`, work through the following: + +1. **Read the release notes.** Each release publishes notes alongside the chart. + Check them for breaking changes, required values updates, deprecations, and + any per-upgrade migration warnings. +2. **Check your current chart version.** Run `helm list -n hub` and confirm the + version currently installed against the version you intend to install. If you + are skipping multiple versions, read the notes for every intermediate + release. Migrations from each are applied in order, and a step you skip may + include a breaking values change you need to honor. +3. **Diff your values.** If you keep your `values.yaml` in source control, + compare it against the new chart's defaults. Run `helm show values + oci://xpkg.upbound.io/upbound/hub --version ` to see the new + defaults and check for renamed or removed keys. +4. **Stage the upgrade.** Run the upgrade against a non-production installation + first, ideally one that mirrors your production values and points at a copy + of your production schema. Confirm the migration completes, the new Pods come + up, and your existing connectors continue to register. +5. **Back up Postgres.** Take a fresh backup of the Hub database immediately + before the upgrade. Migrations are forward-only. If you need to revert to an + older chart, you may need the backup to restore the previous schema. Use your + database provider's native snapshot facility, such as an RDS snapshot. +6. **Plan the maintenance window.** `helm upgrade` blocks on the migration Job + before it rolls any new `hub-core` Pods. Your existing Pods keep serving while + the migration runs. The window depends on the size of the migration; small + upgrades typically take seconds, but a migration that rewrites a large table + can take longer. Notify any clients of `hub-core` of the expected window. + +:::warning +Database migrations can't be reversed automatically. Always take a backup of +the Hub database before upgrading. +::: + +## Upgrade procedure + +The upgrade is a standard `helm upgrade` against the `hub` chart. + +1. Run the upgrade with your existing values file: + + ```bash + helm upgrade hub oci://xpkg.upbound.io/upbound/hub \ + --version \ + --namespace hub \ + --values values.yaml + ``` + + Replace `` with the chart version you are installing and + `values.yaml` with the path to the values file you used for the original + install. + +2. Watch the rollout: + + ```bash + kubectl -n hub rollout status deployment/hub-core + kubectl -n hub rollout status deployment/hub-webui + ``` + + Because the migration runs as a `pre-upgrade` hook, `helm upgrade` blocks on + the migration Job before it touches the Deployments, so the migration has + already finished by the time these commands report. To follow the migration + while `helm upgrade` is still running, tail the Job from another terminal: + + ```bash + kubectl -n hub logs -l app.kubernetes.io/component=api-migrate -c sql-migrate --follow + ``` + +3. Roll the connectors. Each `hub-connector` install is a separate Helm release + in its host control plane. Upgrade each one to the same chart version using + the same `helm upgrade` shape with its own values file. + +4. Verify: + + - The migration Job completed successfully and the `hub-core` Pods are + `Ready`. + - The Hub UI loads and you can authenticate. + - Each `hub-connector` reconnects and continues reporting ControlPlane + state. + +## Roll back + +Helm supports rolling the chart back with `helm rollback`, but Hub's database +migrations are forward-only and Helm has no awareness of the schema. A naive +rollback can leave your installation with an older binary running against a +newer schema. + +Before running `helm rollback`, check the release notes for the version range +you are crossing. Two cases: + +- **The release notes state the target version is schema-compatible with the + schema currently in your database.** This scenario is the common case for minor or + patch rollbacks where no migration ran, or where the migrations between the + two versions are additive only. In this case `helm rollback` is safe: + + ```bash + helm rollback hub --namespace hub + ``` + + Find `` with `helm history hub -n hub`. + +- **The release notes state the target version isn't schema-compatible with the + current schema.** A `helm rollback` alone doesn't work. You need to restore + the database to a backup taken before the offending upgrade, then run `helm + rollback` to align the chart. Plan for downtime; treat this as a database + restore operation, not a Helm operation. + +If the release notes don't state compatibility explicitly, assume the rollback +is unsafe and contact support before proceeding. + +:::warning +`helm rollback` doesn't reverse database migrations. Rolling the chart back +without verifying schema compatibility can leave `hub-core` unable to start. It can +also run with subtly inconsistent behavior against a schema it doesn't +understand. +::: + +## Next step + +- [Production overview][production-overview]. The rest of the production hardening + checklist. + + +[production-overview]: /hub/howtos/production-overview diff --git a/hub-docs/hub-quickstart.md b/hub-docs/hub-quickstart.md new file mode 100644 index 000000000..432e92206 --- /dev/null +++ b/hub-docs/hub-quickstart.md @@ -0,0 +1,565 @@ +--- +title: Try the hub +description: Run Hub on kind, connect two Crossplane control planes, and query the fleet from one screen. +--- + +This quickstart installs and runs the hub API locally and connects two +Crossplane control planes to it. By the end you have a three control plane fleet +and a Console that queries across the fleet. You can check which resources +exist, types and where they exist, and which control plane runs an older package +version. + +Budget about 30 minutes. Everything runs in local `kind` clusters and needs no +cloud credentials. + +:::warning +This quickstart uses the chart's demo mode. Demo mode bundles PostgreSQL, +Keycloak, and the Envoy Gateway controller with fixed credentials, ephemeral +storage, and a self-signed certificate. Don't run it in production. For a real +install, see [Installing hub][install]. +::: + +## What you build + +| Cluster | Role | +| --- | --- | +| `hub` | Runs hub, the bundled Postgres and Keycloak, and the Console. Registers itself as the `default` control plane. | +| `ctp-payments` | Crossplane control plane running `provider-nop` v0.4.0. | +| `ctp-analytics` | Crossplane control plane running `provider-nop` v0.5.0. | + +The two control plane clusters run the same composite type on different provider +versions. That difference is what the fleet views surface later. + +## Prerequisites + +- [Docker](https://docs.docker.com/get-started/get-docker/) with at least 8 CPU cores + and 8 GB of memory available +- [kind](https://kind.sigs.k8s.io/docs/user/quick-start/#installation) v0.20 or + later +- [kubectl](https://kubernetes.io/docs/tasks/tools/) +- [Helm](https://helm.sh/docs/intro/install/) 3 +- The [up CLI][upCli] and an Upbound account + +Log in before you start. The hub chart lives in Upbound's registry: + +```shell +up login +``` + +## Step 1: Create the hub cluster + +Demo mode exposes the Console through an Envoy Gateway on node port `30443`. Map +that node port to a host port so you can open the Console in a browser. + +1. Write the cluster config. + + ```yaml title="hub-cluster.yaml" + kind: Cluster + apiVersion: kind.x-k8s.io/v1alpha4 + name: hub + nodes: + - role: control-plane + extraPortMappings: + - containerPort: 30443 + hostPort: 8443 + protocol: TCP + ``` + +2. Create the cluster. + + ```shell + kind create cluster --config hub-cluster.yaml + ``` + + kind attaches every cluster it creates to a shared Docker network named + `kind`, and names the node container `-control-plane`. The + control plane clusters you create in step 4 reach this one at + `hub-control-plane`. + +## Step 2: Install hub + +1. Install the umbrella chart with demo mode on. + + The two `NodePort` settings expose the `hub-core` API and its token exchange + endpoint so connectors running in the other kind clusters can reach them. A + production install routes this traffic through a gateway instead. + + ```shell + helm upgrade --install hub oci://xpkg.upbound.io/upbound/hub \ + --namespace hub --create-namespace \ + --version 1.0.0 \ + --set global.demo.enabled=true \ + --set hub-core.api.service.api.type=NodePort \ + --set hub-core.api.tokenExchange.service.type=NodePort + ``` + +2. Wait for the stack to come up. The first install runs a database migration + before `hub-core` starts, so this takes up to 5 minutes. + + ```shell + kubectl --context kind-hub -n hub wait --for=condition=ready pod --all --timeout=10m + ``` + +3. Read back the node ports Kubernetes assigned. Step 6 points the connectors at + them, so stay in this shell or note the values down. + + ```shell + HUB_API_PORT=$(kubectl --context kind-hub -n hub get svc hub-core \ + -o jsonpath='{.spec.ports[?(@.name=="http")].nodePort}') + HUB_TOKEN_PORT=$(kubectl --context kind-hub -n hub get svc hub-core-token-exchange \ + -o jsonpath='{.spec.ports[?(@.name=="token-exchange")].nodePort}') + + echo "api=$HUB_API_PORT token-exchange=$HUB_TOKEN_PORT" + ``` + + :::note + Chart version 1.0.0 rejects an explicit + `hub-core.api.service.api.nodePort`, so you can't pin these ports at install + time. Kubernetes allocates them from the node port range instead. + ::: + +Demo mode bootstraps four things for you: + +- A Keycloak identity provider named `keycloak`, with a set of demo users. +- A `default` control plane in the `default` realm, representing the hub cluster + itself. +- An `OrganizationRoleBinding` granting the `keycloak:admin` group organization + admin. +- A `RealmRoleBinding` granting the same group admin on the `default` realm. + +:::info +A realm is the namespace a control plane lives in. Organization roles govern +realms, identity, and role bindings. Realm roles govern the control planes and +resources inside a realm. Both bindings exist because organization admin alone +doesn't grant access to a realm's contents. See [Access and +authorization][rbac]. +::: + +## Step 3: Sign in to the Console + +1. Open `https://hub.127.0.0.1.nip.io:8443`. + + Demo mode serves the Console, the hub API, and Keycloak from this one + hostname, routed by path. The gateway presents a self-signed certificate, so + your browser warns you on first visit. Choose **Advanced**, then proceed. + +2. Sign in as `admin` with the password `admin`. + +3. Open the control planes view. One control plane, `default`, is already + registered and `Ready`. Its resources are the hub cluster's own. + +Demo mode also creates users with narrower access. Keep them for step 7: + +| User | Password | Access | +| --- | --- | --- | +| `admin` | `admin` | Organization admin and realm admin on `default` | +| `editor-alice` | `password` | Editor | +| `editor-bob` | `password` | Editor | +| `viewer-charlie` | `password` | Read-only | + +## Step 4: Create two Crossplane control planes + +Each cluster gets UXP, `provider-nop` at a different version, a composite +resource definition, and composite resources. The differing provider +versions give the fleet views something to compare. + +1. Create the two clusters. + + ```shell + kind create cluster --name ctp-payments + kind create cluster --name ctp-analytics + ``` + +2. Install UXP in each one. + + ```shell + for ctx in kind-ctp-payments kind-ctp-analytics; do + up uxp install --kubecontext "$ctx" + kubectl --context "$ctx" -n crossplane-system \ + wait --for=condition=ready pod --all --timeout=5m + done + ``` + +3. Install the packages. `ctp-payments` gets `provider-nop` v0.4.0 and + `ctp-analytics` gets v0.5.0. + + ```shell + install_packages() { + kubectl --context "$1" apply -f - < + ``` + + :::warning + The Console shows the token once. It's valid for 24 hours and single-use. If + you lose it, reissue the token, which invalidates the old one. + ::: + +4. Repeat for `analytics`. + + ```shell + ANALYTICS_TOKEN= + ``` + +## Step 6: Install the connector + +The connector runs inside each control plane cluster, exchanges its registration +token for a hub credential, and streams resource state to `hub-core`. + +1. Install the connector in `ctp-payments`. + + ```shell + kubectl --context kind-ctp-payments create namespace upbound-system + + kubectl --context kind-ctp-payments -n upbound-system \ + create secret generic hub-connector-credentials \ + --from-literal=registrationToken="$PAYMENTS_TOKEN" + + helm install hub-connector oci://xpkg.upbound.io/upbound/hub-connector \ + --kube-context kind-ctp-payments \ + --namespace upbound-system \ + --version 1.0.0 \ + --set connector.hub.url=http://hub-control-plane:$HUB_API_PORT \ + --set connector.hub.tokenExchangeUrl=http://hub-control-plane:$HUB_TOKEN_PORT \ + --set connector.hub.allowInsecure=true + ``` + + `$HUB_API_PORT` and `$HUB_TOKEN_PORT` come from step 2. + `connector.hub.allowInsecure` permits plaintext HTTP to the hub. It's needed + here because the node ports you exposed in step 2 don't end TLS. A real + install points the connector at an HTTPS gateway and leaves this at its + default of `false`. + +2. Install the connector in `ctp-analytics`. + + ```shell + kubectl --context kind-ctp-analytics create namespace upbound-system + + kubectl --context kind-ctp-analytics -n upbound-system \ + create secret generic hub-connector-credentials \ + --from-literal=registrationToken="$ANALYTICS_TOKEN" + + helm install hub-connector oci://xpkg.upbound.io/upbound/hub-connector \ + --kube-context kind-ctp-analytics \ + --namespace upbound-system \ + --version 1.0.0 \ + --set connector.hub.url=http://hub-control-plane:$HUB_API_PORT \ + --set connector.hub.tokenExchangeUrl=http://hub-control-plane:$HUB_TOKEN_PORT \ + --set connector.hub.allowInsecure=true + ``` + +3. Confirm both connectors are running. + + ```shell + for ctx in kind-ctp-payments kind-ctp-analytics; do + kubectl --context "$ctx" -n upbound-system wait --for=condition=ready pod \ + --selector app.kubernetes.io/name=hub-connector --timeout=3m + done + ``` + +4. Refresh the Console. The control planes view now lists `default`, `payments`, + and `analytics`, all `Ready`. + + A control plane stays `Pending` until its connector registers. If one doesn't + turn `Ready`, see [Troubleshooting](#troubleshooting). + +## Step 7: Query the fleet + +Everything below happens in one Console, against all three control planes, with +no `kubectl` context switching. + +### Find resources across control planes + +Open the resources view. It lists every resource the connectors report, from all +three control planes at once, with sort, filter, and search. + +Try these: + +- Search for `XApp`. Four composites come back: three from `payments`, one from + `analytics`. +- Filter by control plane `analytics`. The list narrows to that cluster without + changing the query. +- Filter by health to isolate resources that aren't `Ready`. + +:::note +By default the connector syncs only resources authorized by the +`crossplane-admin` ClusterRole, so you see Crossplane resources rather than every +object in the cluster. Widen `connector.sync.limitToClusterRoles`, or set it to +`[]`, to sync more. +::: + +### Roll resources up into counts + +Group the resource list by health, label, annotation, or creation time. +Each grouping answers a fleet-wide question in one screen, such as how many +composites are `Ready` right now across all three control planes. + +Hub keeps these aggregations as time series, so the same counts also show up as +trends rather than a single snapshot. The trend line is thin right now because +your fleet is minutes old. + +### Compare types across the fleet + +Open the types view. Hub indexes the CRDs and XRDs installed in every connected +control plane, so `XApp` appears once with both `payments` and `analytics` +listed underneath it. + +This view is where schema drift shows up. Change the XRD in one cluster and the +two control planes stop agreeing on the same type: + +```shell +kubectl --context kind-ctp-analytics patch xrd xapps.example.upbound.io \ + --type=json \ + -p='[{"op":"add","path":"/spec/names/shortNames","value":["xa"]}]' +``` + +After the next rediscovery interval, about 20 seconds, the types view reflects +the change on `analytics` only. + +### Spot package version drift + +Open the packages view. Hub lists every Provider, Configuration, and Function +across the fleet alongside the version each control plane runs. + +`provider-nop` appears with two versions: v0.4.0 on `payments` and v0.5.0 on +`analytics`. You set this drift up in step 4, and finding it takes a single +lookup instead of one `kubectl get providers` per cluster. + +Upgrade `payments` to match and watch the entry collapse to one version: + +```shell +kubectl --context kind-ctp-payments patch provider provider-nop \ + --type=merge \ + -p '{"spec":{"package":"xpkg.upbound.io/crossplane-contrib/provider-nop:v0.5.0"}}' +``` + +### See how access scopes the view + +Sign out and sign back in as `viewer-charlie` with the password `password`. + +The fleet views only show control planes in realms the signed-in user can access, +and counts reflect that scope. An operator with partial access sees partial +totals, not an error. Sign back in as `admin` to restore the full view. + +:::note +Catalog, which indexes the package images behind those providers and makes them +searchable, is a preview feature and off by default. See [Catalog][catalog] to +enable it. +::: + +## Troubleshooting + +### A control plane stays Pending + +The connector hasn't completed registration. Check its logs: + +```shell +kubectl --context kind-ctp-payments -n upbound-system logs deployment/hub-connector +``` + +The most common cause is the connector failing to reach +`hub-control-plane:$HUB_API_PORT`, which happens when the cluster didn't join the shared +`kind` Docker network. Attach it and restart the connector: + +```shell +docker network connect kind ctp-payments-control-plane +kubectl --context kind-ctp-payments -n upbound-system \ + rollout restart deployment hub-connector +``` + +### The connector logs an authentication error + +The registration token expired, or something already used it. Reissue a token +for the control plane in the Console, update the secret, and restart the +connector: + +```shell +kubectl --context kind-ctp-payments -n upbound-system \ + delete secret hub-connector-credentials + +kubectl --context kind-ctp-payments -n upbound-system \ + create secret generic hub-connector-credentials \ + --from-literal=registrationToken="" + +kubectl --context kind-ctp-payments -n upbound-system \ + rollout restart deployment hub-connector +``` + + +### The Console won't load + + + +Confirm the gateway data plane is serving on node port `30443`: + +```shell +kubectl --context kind-hub -n hub get svc \ + -l gateway.envoyproxy.io/owning-gateway-name=hub-gateway +``` + +If you created the `hub` cluster without the `extraPortMappings` from step 1, +reach the Console with a port-forward instead: + +```shell +kubectl --context kind-hub -n hub port-forward \ + "svc/$(kubectl --context kind-hub -n hub get svc \ + -l gateway.envoyproxy.io/owning-gateway-name=hub-gateway \ + -o jsonpath='{.items[0].metadata.name}')" 8443:8443 +``` + +### Resources appear for a control plane but not the ones you expect + +The connector syncs only what the `crossplane-admin` ClusterRole authorizes. +Widen the filter and upgrade the release: + +```shell +helm upgrade hub-connector oci://xpkg.upbound.io/upbound/hub-connector \ + --version 1.0.0 \ + --kube-context kind-ctp-payments \ + --namespace upbound-system \ + --reuse-values \ + --set 'connector.sync.limitToClusterRoles=[]' +``` + +## Clean up + +Delete all three clusters: + +```shell +kind delete cluster --name ctp-analytics +kind delete cluster --name ctp-payments +kind delete cluster --name hub +``` + +## Next steps + +- [Installing the hub API][install] to run against your own PostgreSQL, OIDC + provider, and gateway. +- [Connect a control plane][connect] for the connector install against a real + hub, including the kubectl path for minting registration tokens. +- [Production overview][production] for sizing, high availability, autoscaling, + and upgrades. + +[install]: ./howtos/install.md +[connect]: ./howtos/connect-control-plane.md +[production]: ./howtos/production-overview.md +[rbac]: ./howtos/rbac.md +[catalog]: ./products/insights/catalog/overview.md +[upCli]: /manuals/cli/overview diff --git a/hub-docs/overview/_category_.json b/hub-docs/overview/_category_.json new file mode 100644 index 000000000..54bb16430 --- /dev/null +++ b/hub-docs/overview/_category_.json @@ -0,0 +1,4 @@ +{ + "label": "Overview", + "position": 0 +} diff --git a/hub-docs/overview/index.md b/hub-docs/overview/index.md new file mode 100644 index 000000000..ca28cf1f2 --- /dev/null +++ b/hub-docs/overview/index.md @@ -0,0 +1,54 @@ +--- +title: Hub +slug: / +sidebar_position: 0 +description: Hub is the central cluster in an Upbound Platform deployment, where the platform's central components install. +--- + +Hub is the central cluster in an Upbound Platform deployment. Hub is where the +Upbound products that operate over your fleet install, and it provides the +authentication, storage, and APIs those products build on. Connect your Spaces, +Crossplane, or generic Kubernetes clusters to Hub, and the state they report +becomes the data those products work with - starting with Insights, which +indexes and queries resources across the whole fleet. + +## Operating a fleet of control planes + +Each Crossplane control plane answers questions only about itself. Comparing +provider versions, finding recent Composition changes, or checking claim health +across a fleet means querying one cluster's context at a time. Most teams cover +the gap with scripts and dashboards they maintain themselves. + +Hub closes that gap by collecting resource state from every control plane you +connect into one place. Each connected cluster reports what it's running, and Hub +keeps a continuously updated picture of the whole fleet. The Upbound products +running on Hub read from that picture, so a question about your fleet gets one +answer from one place instead of one answer per cluster. + +## What you can do with Hub + +* **Manage a fleet of Crossplane control planes.** Register new control planes, + deregister ones you no longer want to see, and keep a single inventory across + teams, regions, and clouds. + +* **Query resources across all connected control planes at once.** Sort, filter, + and search spanning every cluster Hub has connected, from a single query + instead of one per cluster. + +* **Roll resources up into aggregate statistics.** Group them by health, by + labels and annotations, or by creation and deletion timestamps. Ask "how many + claims are Ready across the fleet right now?" without writing a query per + cluster. + +* **Track those statistics over time.** Hub keeps time-series state for the same + aggregations, so resource counts, health, and turnover show up as trends + instead of point-in-time snapshots. + +* **See every type defined across the fleet, and where it varies.** Hub indexes + the CRDs and XRDs installed in every control plane, so you can compare the + same type across clusters and spot the one running an older schema or a + locally patched spec. + +* **Track installed Crossplane packages across the fleet.** Hub lists every + Provider, Configuration, and Function alongside the version each control plane + runs, so you can spot version drift. diff --git a/hub-docs/products/insights/agent-sessions/overview.md b/hub-docs/products/insights/agent-sessions/overview.md new file mode 100644 index 000000000..fd0115348 --- /dev/null +++ b/hub-docs/products/insights/agent-sessions/overview.md @@ -0,0 +1,100 @@ +--- +title: Agent sessions +sidebar_position: 1 +description: Ask questions about the resources across your fleet in a chat session backed by the agent API. +draft: true +--- + +Agent sessions add a conversational surface to Hub. The feature serves the +`agent.hub.upbound.io/v1alpha1` API group, which exposes session and message +endpoints under `/apis/agent.hub.upbound.io/v1alpha1/`. You ask about the +Crossplane resources in your fleet and the agent answers from the state Hub +already indexes. + +:::note +Agent sessions is an alpha feature. It's disabled by default, and its API may +change in incompatible ways between releases. See the [feature +lifecycle](../../../reference/feature-releases.md). Set the `AgentSessions` gate in +your Helm values to turn it on. It also needs an Anthropic API key, or +`hub-core` refuses to start. See [Feature +flags](../../../reference/feature-flags.md). +::: + +## Concepts + +| Resource | What it declares | +| --- | --- | +| `Session` | A conversation. Cluster-scoped, owned by the user who created it. | +| `sessions/messages` | The subresource you post a message to. The reply streams back on the same request. | + +A `Session` has one settable field, `spec.title`. Hub assigns the session name +itself, ignoring any name you supply on create. `status.messages` holds the +conversation history and is only populated on a get, not on a list. + +## What the agent can see + +The agent answers using two read-only tools against Hub's indexed fleet state: + +| Tool | What it returns | +| --- | --- | +| `query_resources` | A filtered list of resources across connected control planes, by kind, group, control plane, realm, space, namespace, health, or free-text search. Capped at 100 results per call. | +| `get_resource` | One resource by name, including its full Kubernetes object with `metadata.managedFields` stripped. | + +Both read what the connectors report, so the agent can't see a resource a +connector doesn't sync and can't reach a control plane directly. Neither tool +writes. See [Connect a control +plane](../../../howtos/connect-control-plane.md) for what the connector syncs by +default. + +## Access + +Sessions are private to the user who created them. Every session endpoint scopes +its lookup by the authenticated user's name, so you can't read or delete another +user's session. + +:::warning +The agent doesn't scope the resources it reads to the caller. In this release +both agent tools query with an unconstrained view, so any user who can create a +session can ask it about every resource in the hub, including control planes in +realms they can't otherwise view. Upbound plans to release per-session +authorization scoping in a future release. + +Access to the feature is the control mechanism today. Organization admins have +`session` and `sessions/messages` resources access by default. Other users have +no access to the feature. + +Don't enable this feature if that grant is wider than the fleet visibility you +intend. See [Access and authorization](../../../howtos/rbac.md). +::: + + +## The Anthropic API dependency + + +The agent calls the Anthropic API using the `claude-sonnet-4-6` model and doesn't +start without an API key. Plan for both of these: + +- Hub needs network egress to the Anthropic API from the `hub-core` namespace. +- Conversation content leaves your cluster. The Anthropic API receives resource + names, labels, and status messages as part of the conversation. + +## Limits + +| Limit | Value | +| --- | --- | +| Request body | 1 MB | +| Tool call timeout | 30 seconds | +| Conversation history loaded per session | 500 events | +| Results per `query_resources` call | 100 | + + +Because the message stream has no server write deadline, it won't cut off long +replies. The stream ends when the client disconnects, or when the Anthropic API hits +its own timeout. + + +## See also + +- [Start a troubleshooting session](troubleshooting-session.md) +- [Feature flags](../../../reference/feature-flags.md) +- [Feature lifecycle](../../../reference/feature-releases.md) diff --git a/hub-docs/products/insights/agent-sessions/troubleshooting-session.md b/hub-docs/products/insights/agent-sessions/troubleshooting-session.md new file mode 100644 index 000000000..5cafd9bf0 --- /dev/null +++ b/hub-docs/products/insights/agent-sessions/troubleshooting-session.md @@ -0,0 +1,170 @@ +--- +title: Start a troubleshooting session +sidebar_position: 3 +description: Create a session, ask about a failing resource, and read the streamed reply. +draft: true +--- + +This guide walks through one use case: a composite resource somewhere in the +fleet isn't becoming `Ready`, and you want to find it and understand why without +knowing which control plane it's on. + +## Before you start + +- [Enable agent sessions](../../../reference/feature-flags.md) and confirm the API group responds. +- An account in an organization admin group. No other role is granted the + session resources. + + +The examples use two shell variables. Set `HUB_URL` to the base URL clients use +to reach `hub-core`, the same value as `hub-core.api.externalURL`: + +```shell +HUB_URL=https://api. +``` + +`TOKEN` is a **hub** token, not the token your identity provider issued. +`hub-core` rejects an IdP access or ID token presented directly with a `401`. +Exchange the IdP token for a hub token first, using the [RFC +8693](https://datatracker.ietf.org/doc/html/rfc8693) endpoint: + +```shell +TOKEN=$(curl -sS -X POST \ + "$HUB_URL/apis/tokenexchange.hub.upbound.io/v1alpha1/tokenexchangerequests" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \ + -d "subject_token=$IDP_TOKEN" \ + -d "subject_token_type=urn:ietf:params:oauth:token-type:jwt" \ + -d "scope=upbound:org:default" | jq -r .access_token) +``` + +`$IDP_TOKEN` is an access token for the identity provider you registered with +Hub, given by your provider. Hub tokens are short-lived, so +repeat the exchange when calls start returning `401`. + +`scope` names the organization. A self-hosted Hub is a single organization named +`default`. See [Access and authorization](../../../howtos/rbac.md). + +## Step 1: Create a session + +Post a `Session`. The `Session` spec contains only the `title` field and Hub assigns the name: + +```shell +SESSION=$(curl -sS "$HUB_URL/apis/agent.hub.upbound.io/v1alpha1/sessions" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "apiVersion": "agent.hub.upbound.io/v1alpha1", + "kind": "Session", + "spec": {"title": "Composites stuck not ready"} + }' | jq -r '.metadata.name') + +echo "$SESSION" +``` + +The name looks like `ses_2f9k...`. Hub ignores a name you set yourself, so read +it back from the response rather than choosing one. + +## Step 2: Ask a question + +Post to the `messages` subresource. The reply streams back on the same request +as server-sent events, so pass `-N` to stop curl buffering it: + +```shell +curl -sSN "$HUB_URL/apis/agent.hub.upbound.io/v1alpha1/sessions/$SESSION/messages" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"content": "Which composite resources are not ready across the fleet, and what is the most common reason?"}' +``` + +Each frame is a `data:` line holding one JSON event, and the stream ends with a +literal `data: [DONE]`: + +```text +data: {"type":"tool_call","content":"{\"tool\":\"query_resources\",\"arguments\":{\"crossplaneType\":\"xr\",\"ready\":false}}"} + +data: {"type":"tool_result","content":"..."} + +data: {"type":"text_delta","content":"Three composites"} + +data: {"type":"text_delta","content":" are not ready."} + +data: [DONE] +``` + +| Event `type` | Meaning | +| --- | --- | +| `message` | A complete message. | +| `text_delta` | One chunk of the reply. Concatenate these in order. | +| `tool_call` | The agent called a tool. `content` holds `{"tool": ..., "arguments": ...}`. | +| `tool_result` | What the tool returned. | +| `error` | The turn failed. The reply ends here. | + +The `tool_call` events are worth reading. They show which query the agent ran, +which tells you whether it looked where you meant. + +## Step 3: Narrow the conversation + +Sessions are stateful, so the next message continues the same thread. Post again +to the same session and refer to the earlier answer: + +```shell +curl -sSN "$HUB_URL/apis/agent.hub.upbound.io/v1alpha1/sessions/$SESSION/messages" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"content": "Show me the full object for the first one, and explain the Synced condition."}' +``` + +The agent calls `get_resource` for that resource and answers from the full +Kubernetes object. + +## Step 4: Read the history + +A get returns the conversation in `status.messages`: + +```shell +curl -sS "$HUB_URL/apis/agent.hub.upbound.io/v1alpha1/sessions/$SESSION" \ + -H "Authorization: Bearer $TOKEN" | jq '.status.messages' +``` + +Each entry has a `role` of `user`, `assistant`, or `tool`, a `timestamp`, and +either `content` or `toolCalls`. + +Listing sessions omits the messages for performance, so use a get when you want +the transcript: + +```shell +curl -sS "$HUB_URL/apis/agent.hub.upbound.io/v1alpha1/sessions" \ + -H "Authorization: Bearer $TOKEN" \ + | jq -r '.items[] | "\(.metadata.name)\t\(.spec.title)"' +``` + +## Step 5: Clean up + +Rename a session with a `PUT`, which updates the title and nothing else, or +delete it: + +```shell +curl -sS -X DELETE \ + "$HUB_URL/apis/agent.hub.upbound.io/v1alpha1/sessions/$SESSION" \ + -H "Authorization: Bearer $TOKEN" +``` + +## Troubleshooting + + +| Symptom | Cause | +| --- | --- | +| `404` on the messages endpoint | The session doesn't exist, or it belongs to another user. Sessions are private to their creator, and posting a message never creates one. Create the session first. | +| `400 content is required` | The body was empty or only whitespace. | +| `401` | No authenticated user on the request. | +| One `error` event, then the stream ends | The turn failed. An invalid Anthropic API key surfaces here rather than at startup. | +| `agent produced no response` | The agent returned no events at all. Check the `hub-core` logs and that the Anthropic API is reachable from the `hub-core` namespace. | +| The reply stops mid-thought after 30 seconds | A tool call hit its 30 second timeout. | + + +## See also + +- [Agent sessions overview](overview.md) +- [Filtering resource lists](../resource-exploration/filtering-resources.md), which + uses the same query surface the agent's `query_resources` tool calls. diff --git a/hub-docs/products/insights/catalog/console.md b/hub-docs/products/insights/catalog/console.md new file mode 100644 index 000000000..2e71fbe43 --- /dev/null +++ b/hub-docs/products/insights/catalog/console.md @@ -0,0 +1,151 @@ +--- +title: Browsing the Catalog +sidebar_position: 2 +description: Find, filter, and inspect packages across your fleet from the Console. +--- + +Once Catalog is [enabled](../../../reference/feature-flags.md), the Console has a **Catalog** +entry in the navigation sidebar and on the home page. +Open either one to see every Crossplane package image the hub has +indexed, along with where each one runs and the APIs it declares. + +:::note +Catalog is a preview feature. Its interface may change between releases. See the +[feature lifecycle](../../../reference/feature-releases.md). +::: + +## The package list + +The Catalog page shows a table of every package image the hub has indexed. For +each package you can see its name, version, type (Provider, Configuration, or +Function), and how many control planes it's deployed on. On larger screens, a +summary panel on the right shows aggregate counts across the full catalog. + +![The Catalog package list with filter and summary panels](/img/hub/catalog/catalog-list.png) + +## Searching and filtering + +Use the search bar above the table to search across package names and +descriptions. A filter panel offers two facets: + +- **Package Type**: checkboxes for Provider, Configuration, and Function. + Select one or more to narrow the list. +- **Deployment Status**: radio buttons for Deployed or Not Deployed. + +On larger screens the filter panel appears to the left of the table. On smaller +screens it appears inline above the table. + +![The Catalog page on a smaller screen with filters inline above the table](/img/hub/catalog/catalog-list-narrow.png) + +Click any column header to sort. Each sortable column supports ascending and descending +order. + +## Understanding deployment status + +The **Deployed** column shows how many control planes have this package +installed. This count reflects your access: it only includes control planes +in realms where you're authorized. + +:::warning +Unless you have access to every control plane in the organization, deployment +counts are partial. A package showing "Not deployed" may still be running on +control planes you don't have permission to view. Organization administrators +see a complete picture. For details on how access scoping works, see [Access and +authorization](/hub/howtos/rbac). +::: + +- **"Deployed on 5 control planes"**: five control planes that you have + permission to view run this package. +- **"Not deployed"**: no control plane you can access runs this package. + Other control planes in the organization may still have it installed. + +## Package details + +Click any row to open a detail drawer with three tabs. + +### Package metadata + +The first tab shows metadata for the selected package: + +- **Repository**: the full OCI repository path. +- **Version**: the image tag. +- **Type**: Provider, Configuration, or Function. +- **Deployed**: the access-scoped control plane count. +- **APIs**: how many Kubernetes APIs (CRDs) the package declares. +- **Enrichment**: whether the hub has fetched additional metadata from the + upstream registry. +- **Digest**: the full image digest. +- **Discovered**: when the hub first indexed this image. + +![Package detail drawer showing the metadata tab](/img/hub/catalog/detail-overview.png) + +### APIs + +The APIs tab lists each custom resource definition the package introduces. +Every entry shows the Kind, API group and version, and scope (Cluster or +Namespaced). + +Below each API, a summary line shows: + +- Total resource count across all visible control planes. +- Number of active control planes running resources of this type. +- Number of control planes with zero resources of this type. + +Expand an API to see two sub-views: + +- **Control planes**: a table of each control plane running this API, its + realm, and its resource count. +- **Schema**: the OpenAPI schema for the resource, rendered as YAML. + +![Package detail drawer showing the APIs tab with an expanded API](/img/hub/catalog/detail-apis.png) + +### Usage + +The Usage tab lists the control planes where the package runs. Each +entry shows the control plane name, its realm, and a resource count. + +When a package isn't on any control plane you can access, the tab +shows: + +> This package isn't installed on any control plane you can access. + +
+![Package detail drawer showing the Usage tab](/img/hub/catalog/detail-usage.png) + +## Registry connections + +At the top of the Catalog page, badges show each connected registry source. +A colored dot indicates connection health: + +| Dot color | Meaning | +| --------- | --------------------------------------------- | +| Green | Healthy—the connection verified successfully. | +| Red | Error—the last verification failed. | +| Gray | Unknown—no verification has run yet. | + +Hover over a badge to see additional details such as the display name, +host, scope, authentication method, and any error information. + +Registry connection badge with hover details + +To add or manage connections, see [External registries](external-registry.md). + +## Good to know + +- **Deployment counts reflect your access.** Counts and control plane lists only + include realms you're authorized to view. See [Understanding deployment + status](#understanding-deployment-status). +- **Large packages limit resource links.** When a package declares more than 30 + API types, the Console disables resource count links in the Usage tab. A hover + label explains why. +- **Data refreshes every 30 seconds.** Use the Refresh button for an immediate + update. +- **Catalog requires a feature flag.** The Console shows the Catalog page only + when the Catalog feature is [enabled](../../../reference/feature-flags.md). + +## See also + +- [Catalog overview](overview.md) +- [External registries](external-registry.md) +- [Feature flags](../../../reference/feature-flags.md) +- [Feature lifecycle](../../../reference/feature-releases.md) diff --git a/hub-docs/products/insights/catalog/external-registry.md b/hub-docs/products/insights/catalog/external-registry.md new file mode 100644 index 000000000..98c989e27 --- /dev/null +++ b/hub-docs/products/insights/catalog/external-registry.md @@ -0,0 +1,71 @@ +--- +title: External registries +sidebar_position: 3 +description: Connect Hub to private or public OCI registries so the catalog can authenticate and enrich the packages your control planes install. +--- + +Connect Hub to your own OCI registries, such as private Artifactory +instances, public registries, or air-gapped mirrors. This allows Hub to +authenticate to them and index images you declare or observe on connected +control planes into the Catalog. + +:::note +External registry connection is an alpha feature. It's +disabled by default, and the APIs may change in incompatible ways between +releases. See the [feature lifecycle](../../../reference/feature-releases.md). +::: + +## Concepts + +Two resources describe how Hub reaches a registry. + +| Resource | What it declares | +| --- | --- | +| `Connection` | How Hub authenticates to a registry: a host, an optional path scope, and credentials. | +| `Repository` | What to index: the full OCI path of a repository, and optionally which `Connection` to use for it. | + +Both resources belong to a [realm](../../../howtos/rbac.md). For example, +credentials declared in one realm are never used to pull for another implicitly. + +## How the catalog uses connections + + +When a connected control plane installs a Crossplane package, +Hub records its data in the catalog by pulling +the package's manifest and content layers from the registry. + + +Cataloguing is independent of whether the control plane's own image pull +succeeds. A connected control plane uses its own `packagePullSecrets`, whereas +Hub pulls with the set of `Connection` resources that make up the keychain in +the realm. + +### The realm keychain + +Within a realm, all `Connection` resources form a keychain. When Hub needs to +pull an image, for cataloguing or independently verifying a `Connection`, it +selects the `Connection` whose `scope` is the longest prefix of the image path. +One realm can hold multiple credentials for the same host, each scoped to a +different path. + + +A `Repository` can opt out of keychain resolution by pinning a single +`Connection` with `spec.connectionRef`. Pin a connection when policy requires +that a repository's credentials can't be resolved via the keychain. + + +## Troubleshooting + +| Symptom | Fix | +| --- | --- | +| `404` on the registry API group | Enable the `Registry` gate (`hub-core.api.featureFlags.gates.Registry=true`). | +| `401` on Hub API calls | The bearer token expired. Create a fresh one. | +| Verify succeeds but enrichment fails with an auth error | The `Connection` `scope` must be a prefix of the image path, and the `Connection` must be in the same realm as the control plane. | +| Verify tier-2 returns `forbidden` | The credential authenticates but isn't authorized to pull that image. Grant read on the repository. | +| `connection refused` from Hub | The registry host must be reachable from Hub's network. | +| Static auth rejected at create | `authMethod: Static` requires `spec.static` with a username and a secret. `Anonymous` must omit `spec.static`. | + +## See also + +- [Catalog overview](overview.md) +- [Feature flags](../../../reference/feature-flags.md) diff --git a/hub-docs/products/insights/catalog/overview.md b/hub-docs/products/insights/catalog/overview.md new file mode 100644 index 000000000..f0fe986aa --- /dev/null +++ b/hub-docs/products/insights/catalog/overview.md @@ -0,0 +1,67 @@ +--- +title: Catalog +sidebar_position: 1 +description: Index and search the package images running across your fleet through Hub's Catalog API. +--- + +Catalog is an index of the package images running across your +connected control planes, queryable through the Hub API. + +Query the Catalog to look up an image, see where the resources it declares run +across your fleet, and search everything Hub has indexed without inspecting +each control plane on its own. + +The Catalog works with the Resource API to give you both halves of a global +view. The Resource API tells you what's running right now; the Catalog tells you +which packages declared it and what else those packages can define. Together +they connect a live resource back to the image that supplied its type, and an +image forward to every resource running from it across the fleet. + +![The Catalog page in the Console](/img/hub/catalog/catalog-list.png) + +:::note +Catalog is an alpha feature. It's disabled by default, and its API may change in +incompatible ways between releases. See the [feature +lifecycle](../../../reference/feature-releases.md) for what the alpha stage +guarantees, and avoid relying on Catalog for production workloads. +::: + + +## What Catalog does + + +The Catalog API serves package data through two resources: + +- `Image`: Lists and gets the package images Hub has indexed. `Image` exposes two + subresources: + - `Usage`: The image's footprint across your fleet: which control planes run + it and how many resources of its types they have. + - `Openapi`: The OpenAPI schemas for the resource types the package declares, + so you can inspect an API without installing the package. +- `ImageSearch`: Searches across the indexed images and their descriptions. + +All catalog image data is visible to the organization but users only see the +correlated resources and control planes for realms they have access to. + + +## Use cases + + +- Inventory package images running across every connected control plane, from + one API. +- Find where a specific image or its resources run through the `usage` subresource. +- Search the indexed images with `ImageSearch` instead of querying each control + plane. + +## Related resources + +To start using Catalog, see: + +**How-to guides** + +- [Browsing the Catalog](console.md) + +**Reference** + +- [Feature flags](../../../reference/feature-flags.md) +- [Feature lifecycle](../../../reference/feature-releases.md) diff --git a/hub-docs/products/insights/definitions.md b/hub-docs/products/insights/definitions.md new file mode 100644 index 000000000..4d9201e1a --- /dev/null +++ b/hub-docs/products/insights/definitions.md @@ -0,0 +1,168 @@ +--- +title: Definitions +sidebar_position: 3 +description: See every API type your control planes serve, correlated across the fleet, and find where their schemas diverge. +--- + +Definitions is a fleet-wide index of the API types your connected control +planes serve. Hub correlates each type by API group and kind, so one row +represents the same type wherever it appears. + +:::note +The `AggregatedTypes` gate serves this view and [Packages](packages.md). It +defaults to on. See [Feature +flags](../../reference/feature-flags.md#aggregated-types) to turn it off. +::: + +Every type reaches Hub from one of three sources on a control plane: a +`CustomResourceDefinition`, a Crossplane `CompositeResourceDefinition`, or a +Crossplane `ManagedResourceDefinition`. Hub tracks the source each control plane +uses and labels the row `CRD`, `XRD`, or `MRD` to match. + +Where the Resources view lists the live objects in your fleet, Definitions +lists the APIs those objects are instances of. + +![The Definitions list in the Console](/img/hub/definitions/definitions-list.png) + +## Opening the view + +Open the navigation menu from the header, then choose **Definitions** under +**Insights**. The tab strip on the Resources page, the quick access list on the +home page, and the command palette all reach the same view, which lives at +`/explore/definitions`. + +Both the menu entry and the tab appear only when the API is available. When the +gate is off, Hub hides them and redirects the page to Resources. + +## The definitions list + +Each row is one correlated type. The table shows: + +- **Source**. `XRD`, `CRD`, or `MRD` badges for how control planes define this + type. A type can carry more than one badge when control planes disagree. +- **Kind** and **API Group**. The type's identity, such as `RDSInstance` in + `rds.aws.upbound.io`. +- **Scope**. `Cluster` or `Namespaced`. +- **Versions**. How many distinct API versions the fleet serves for this type. +- **Control Planes**. How many control planes offer the type. +- **Resources**. How many live resources of this type exist across the fleet. + Select the count to open Resources filtered to that group and kind. +- **Schema**. `Consistent` when every version has one schema fleet-wide, or + `Has Variants` when at least one version has more than one. + +Select a column header to sort. + +## Filtering the list + +A filter bar sits above the table. Choose **Filter** to see the filters this +table offers, then pick one to add it to the bar: + +- **Source Kind**. One of `XRD`, `CRD`, or `MRD`. +- **Control Plane**. One or more control planes. +- **Schema Status**. Either `Consistent` or `Has Variants`. +- **Deprecation Status**. Either `Consistent` or `Has Mismatches`. + +Each filter you add becomes a pill on the bar, labeled with its name and the +current selection. Selecting a pill opens its value list, where Source Kind, +Schema Status, and Deprecation Status take a single value and Control Plane +takes several. Long lists have a search box. Remove one filter with the **×** on +its pill, or clear every filter with **Clear All**. + +Filters combine, so a bar holding Source Kind and Schema Status narrows to rows +matching both. To filter from the table instead, right-click a cell and choose +**Filter by this value**. + +![The Definitions filter bar with a value list open](/img/hub/definitions/definitions-filters.png) + +## Finding schema drift + +Two control planes can serve the same kind and version with different schemas. +Hub calls each distinct schema a variant and flags the type as +`Has Variants`. + +Hub also compares deprecation state. When one control plane marks a version +deprecated and another doesn't, the row carries a warning badge for the +mismatch. + +Hub scopes both signals within a version. A type with `v1beta1` and `v1` stays +consistent as long as each of those versions has one schema everywhere. + +## Definition details + +Select a row to open a detail drawer with two tabs. + +### Type details + +The **Overview** tab shows the API group, kind, scope, Crossplane categories, +source kind, the number of versions available, the live resource count, and the +number of control planes offering the type. The resource count links into +Resources, narrowed to that type. + +![The Overview tab of the definition detail drawer](/img/hub/definitions/definition-overview.png) + +### API schemas + +The API Schemas tab groups schemas by version. Each version lists its variants, +identified by a short schema hash, along with how many control planes serve that +variant and how many resources depend on it. + +Expand a variant to switch between two panels: + +- **Control planes**. The control planes serving that variant, with their realm, + whether each one serves the version, whether it stores resources in that + version, and its deprecation state. +- **Schema**. The schema for that version, rendered as YAML. + +Select two variants, then choose **Compare** to see the two schemas side by +side. + +![The API Schemas tab with two variants selected for comparison](/img/hub/definitions/definition-schemas.png) + +![The schema comparison modal](/img/hub/definitions/definition-schema-compare.png) + +## Access and counts + +Counts reflect your access. Hub aggregates a type across the whole +organization, but the control plane and resource counts, and the rows on the +distribution detail, only include control planes in realms you can reach. + +:::warning +Unless you have access to every control plane in the organization, the counts +are partial. A type that looks consistent to you may have variants on control +planes you can't view. See [Access and +authorization](/hub/howtos/rbac). +::: + +## Common tasks + +- Inventory every API your platform exposes, from one place, without listing + CRDs on each control plane. +- Catch schema drift before it breaks a consumer, by finding types that report + variants. +- Plan a version migration by finding which control planes still serve a + deprecated version. +- Judge blast radius before changing an XRD, using the live resource count for + the type. + +## Querying the API + +The Console reads two resources in the `hub.upbound.io/v1alpha1` API group: + +- `typedefinitions`. Lists and gets correlated types. Filter a list by + `sourceKind`, `hasSchemaVariants`, `hasDeprecationMismatch`, and + `controlPlane`. +- The `distribution` subresource. Per-version detail for one type: its variants, + deprecation state, and the control planes serving each variant. + +## See also + +**Features** + +- [Insights](overview.md) +- [Packages](packages.md) +- [Catalog](catalog/overview.md) + +**Reference** + +- [Feature flags](../../reference/feature-flags.md) +- [Feature lifecycle](../../reference/feature-releases.md) diff --git a/hub-docs/products/insights/lenses/console.md b/hub-docs/products/insights/lenses/console.md new file mode 100644 index 000000000..e625eb3ce --- /dev/null +++ b/hub-docs/products/insights/lenses/console.md @@ -0,0 +1,115 @@ +--- +title: Using lenses in the Console +sidebar_position: 2 +description: Open, save, share, and manage lenses from the Hub Console. +--- + +This guide covers how to open, save, share, and manage lenses in the Hub Console. Lenses let you return to a saved view without rebuilding filters, sort order, or table layout each time. + +## Open a lens + +
+
+ +On the Resources page, open the Lenses panel on the left. The panel lists every lens you can use, grouped into three sections: + +- My Lenses: Lenses created and owned by you +- Shared by Team: Lenses owned by a teammate, accessible to you +- Pre-built Lenses: Default Lenses shipped with the Hub + +Select a lens from the list to apply its saved filter, sort, and column layout. The main area updates to show that lens's name, filters, and results. + +Use the "Search lenses" field at the top of the panel to find a lens by name. + +
+Lens picker +
+ +You can filter and sort the Resources table directly without selecting a lens. +Changes settings will affect what you see right now, but they are not saved +until you create or update a lens. Switching to another lens replaces your +current view with that lens's saved settings. + +## Save your current view as a lens + +
+
+ +New lenses are private by default. They appear under "My Lenses" and show a lock icon. Only you can see and edit them until you share one with your team. + +1. Adjust filters, sort, and columns until the view looks right. Active filters appear as chips above the table. +2. Click "Create Lens" in the Lenses panel header. +3. Enter a name and, if you want, a short description. Hub saves the current filter, sort, and table layout to that lens. + +After you save, the lens appears under "My Lenses". + +
+Lens picker +
+ +## Share a lens with your team + +There are two ways to share a view with teammates, and they do different things. + +### Copy a public URL + +
+
+ +Right-click a lens in the Lenses panel and choose "Copy public URL". Hub copies a link that opens the Resources page with the same filter criteria applied. + +The link contains the current filtering criteria for your teammates to use. Copying the link does not make your lens public. The lens stays private, and teammates open the same view from the URL rather than selecting your lens from the list. + +
+Lens picker +
+ +Use this when you want to send someone a specific view quickly, such as in a chat message or incident thread, without making the lens visible to everyone in Hub. + +:::tip +Sharing the public URL shares the filtering criteria for the lens, not ownership +of the lens itself. +::: + +### Share a lens permanently + +
+
+ +To make a lens available to everyone in Hub, right-click the lens and choose "Edit Lens". In the dialog, change "Visibility" to "Public", then save. + +Shared lenses appear under "Shared by Team", where anyone can select and reuse them. Making a lens public does not give others edit rights. Only you can change or remove a lens you created. + +
+Lens picker +
+ +:::tip +Mark a lens publicly available when your team needs a reliable view of resources they can return to. +::: + +## Edit or delete a lens you own + +Open one of your lenses under "My Lenses", adjust the view, and save your changes from the menu on the lens header. Saving replaces the lens's stored view entirely. + +You cannot edit or delete pre-built lenses or lenses owned by someone else. If you need a variant, adjust the view and click "Create Lens" to save it as a new lens. + +## See also + +- [Lenses](overview.md) diff --git a/hub-docs/products/insights/lenses/overview.md b/hub-docs/products/insights/lenses/overview.md new file mode 100644 index 000000000..bccc6ff20 --- /dev/null +++ b/hub-docs/products/insights/lenses/overview.md @@ -0,0 +1,40 @@ +--- +title: Lenses +sidebar_position: 1 +description: Saved views for Hub data — filters, sort, and table layout you can reuse and share. +--- + +Lenses are a saved view that captures how you want to look at a particular kind of data. + +Think of lenses like a saved search or a bookmark. Instead of re-entering filters, sort order and table layout every time you open the Resources page, you pick a lens and that view is restored. + +Lenses are useful when you return to the same view often, or when your team +should start from the same filters and layout. Sharing a lens enables everyone +to troubleshoots with the same starting point. + +![Lenses in the Console](/img/hub/lenses/lenses-preview.png) +:::note +A lens only narrows what you already have permission to see. It does not grant access to resources you could not view otherwise. +::: + +## What is a lens + +A lens is a saved view of your data. That means Kubernetes objects collected +from your control planes filtered, sorted, and displayed in your preferred way. +For example, A lens named "Not ready pods" might define a filter for "Pod objects whose +Ready condition is false", a sorting of "newest first", and a collection of +columns. + +When you troubleshoot the same problem often, a lens saves you from rebuilding the same filter every time. If you find a useful way to look at your resources, you can share that lens with your team so everyone starts from the same view instead of rediscovering it on their own. + +## Who can see and change a lens + +Lenses can be configured with different visibilities: + +- A private lens is visible only to you. You can open it, change it, and delete it. +- A shared lens is visible to everyone in Hub. Anyone can select it and use it, but only the person who created it can edit or remove it. +- A built-in lens, such as the Upbound presets that ship with Hub, is visible to everyone and cannot be changed or removed by any user. + +## Related resources + +- [Using lenses in the Console](console.md) diff --git a/hub-docs/products/insights/metrics/overview.md b/hub-docs/products/insights/metrics/overview.md new file mode 100644 index 000000000..ba9c558f0 --- /dev/null +++ b/hub-docs/products/insights/metrics/overview.md @@ -0,0 +1,268 @@ +--- +title: Metrics +sidebar_position: 1 +description: Collect Crossplane and resource metrics from every connected control plane and query them fleet-wide with PromQL. +--- + +Metrics gives you one place to ask questions about Crossplane behavior across +every control plane you've connected to Hub. A collector on each control +plane scrapes Crossplane, its providers and functions, filters to a curated set +of metrics, and ships them to Hub over the connector's authenticated channel. +Hub stamps each series with the control plane's identity, writes it to a +Prometheus-compatible backend, and serves it back through a query API that +answers in PromQL. + +The result is a single query surface for the whole fleet. Instead of opening one +Grafana per cluster, you ask "which control planes have reconcile errors right +now" and get one answer covering every control plane you can see. + +You read metrics through the `metrics.hub.upbound.io` API group and its `Query` +resource. + +:::note +Metrics is an alpha feature. It's disabled by default, and its API may +change in incompatible ways between releases. See the [feature +lifecycle](../../../reference/feature-releases.md). Set the `Metrics` gate in your +Helm values to turn it on. It also needs an OTel gateway and a +Prometheus-compatible backend. See [Feature +flags](../../../reference/feature-flags.md#metrics). +::: + +## The pipeline + +Metrics travel the same path as resource data, through the connector already +running on each control plane. + +| Stage | What runs | What it does | +| --- | --- | --- | +| Scrape | Collector in the `hub-connector` chart | Discovers Crossplane Pods, scrapes them, and drops everything outside the curated allowlist. | +| Push | `hub-connector` | Forwards OTLP over the connector's authenticated channel to Hub. | +| Ingest | `hub-core` | Authenticates the connector, resolves its identity, and forwards to the gateway. | +| Stamp and store | OTel gateway in the `hub-core` chart | Adds identity labels, re-applies the allowlist, and remote-writes to the backend. | +| Query | `hub-core` | Accepts PromQL, scopes it to the caller's control planes, and reads from the backend. | + +Only the curated set leaves a control plane, and the gateway applies the same +allowlist again on arrival. A misconfigured or rogue source collector can't +push metrics Hub didn't ask for. + +The backend is Prometheus-compatible and swappable: self-hosted Prometheus or +any remote-write endpoint, Google Managed Prometheus, or Amazon Managed +Prometheus. See [Metrics pipeline](../../../howtos/metrics.md) for backend setup. + +## What the pipeline collects + +Two sources feed the pipeline. Crossplane and its ecosystem expose controller +metrics on their own Pods. Resource State Metrics, an optional component in the +`hub-connector` chart, turns the status conditions of resources in the control +plane into gauges. + +The default allowlist covers both: + +| Metric | Source | What it tells you | +| --- | --- | --- | +| `controller_runtime_reconcile_total` | Crossplane, providers, functions | Reconcile volume by controller and result. | +| `controller_runtime_reconcile_errors_total` | Crossplane, providers, functions | Reconcile failures by controller. | +| `controller_runtime_reconcile_time_seconds.*` | Crossplane, providers, functions | Reconcile latency histogram. | +| `upjet_resource_external_api_calls_total` | Upjet-based providers | Calls a provider makes to the cloud API it fronts. | +| `function_run_function_seconds.*` | Composition functions | Function execution latency histogram. | +| `kube_customresource_.*` | Resource State Metrics | Status conditions of resources in the control plane. | + +The list is a Helm value on both the collector and the gateway, so you can add +metrics your providers expose. Both sides must allow a metric for it to reach +storage. See [adding metrics to the +allowlist](../../../howtos/metrics.md#add-metrics-to-the-allowlist). + +Resource State Metrics emits one `kube_customresource_resource_condition` series +per condition per resource, labeled with `group`, `version`, `kind`, `name`, +`namespace`, and `condition_type`. A value of `1` means the condition is `True`. + +:::warning +Resource State Metrics watches every API group by default, so its series count +scales with the number of resources in the control plane, not with the number of +Crossplane resources. Narrow `rsm.apiGroups` before enabling it on a large +control plane. +::: + +## Querying metrics + +Send a `Query` to `metrics.hub.upbound.io/v1alpha1`. It runs a PromQL range +query and returns the resulting time series. + +```shell +curl -sk -H "Authorization: Bearer $TOKEN" \ + -X POST "/apis/metrics.hub.upbound.io/v1alpha1/queries" \ + -H 'Content-Type: application/json' \ + -d '{ + "apiVersion": "metrics.hub.upbound.io/v1alpha1", + "kind": "Query", + "query": { + "promql": "sum(rate(controller_runtime_reconcile_total[5m])) by (control_plane_name, result)", + "start": "2026-07-29T12:00:00Z", + "end": "2026-07-29T13:00:00Z", + "step": "5m" + } + }' | jq +``` + +The request body carries four required fields: + +| Field | Type | Description | +| --- | --- | --- | +| `query.promql` | string | The PromQL expression. | +| `query.start` | string | Range start, RFC3339. | +| `query.end` | string | Range end, RFC3339. | +| `query.step` | string | Resolution, as a Go duration such as `15s`, `1m`, or `5m`. | + +Hub echoes the request and adds `results.series`. Each series carries its label +set and a list of timestamp and value pairs: + +```json +{ + "kind": "Query", + "apiVersion": "metrics.hub.upbound.io/v1alpha1", + "metadata": {}, + "query": { + "promql": "sum(rate(controller_runtime_reconcile_total[5m])) by (control_plane_name, result)", + "start": "2026-07-29T12:00:00Z", + "end": "2026-07-29T13:00:00Z", + "step": "5m" + }, + "results": { + "series": [ + { + "labels": { + "control_plane_name": "cp-a", + "result": "success" + }, + "values": [ + { "timestamp": 1785326400, "value": 1.0090489265401168 }, + { "timestamp": 1785326700, "value": 2.0181023783336527 } + ] + } + ] + } +} +``` + +Because `Query` is a `POST` against an API group, you can also send it with +`kubectl` using a [`hub` context](../../../howtos/configure-kubectl.md): + +```shell +kubectl --context=hub create --raw \ + /apis/metrics.hub.upbound.io/v1alpha1/queries -f query.json +``` + +## Correlating metrics with fleet data + +Metrics and the resources API describe the same objects from two angles. The +resources API holds current state: conditions, the full manifest, and who changed +what. Metrics hold history: when a condition flipped, how often it flips, and how +one control plane compares to the rest of the fleet. + +`control_plane_name` and `realm` join them at the control plane level. They're +the `ControlPlane` resource's name and namespace in `hub.upbound.io/v1beta1`. +Below that, the two metric families reach resources at different levels: + +| Metric family | Reaches | Through | +| --- | --- | --- | +| `kube_customresource_*` | A single resource | `group`, `kind`, `name`, and `namespace`, which identify the same object the `resources` endpoint returns. | +| `controller_runtime_*` | A resource type | The `controller` label, which names the controller reconciling that type. | + +### The `controller` label + +Crossplane names each controller after the type it reconciles, as +`/.`. So +`composite/xdatabases.platform.example.com` is the controller for the +`XDatabase` composite resources in `platform.example.com`, and +`packages/provider.pkg.crossplane.io` is the one reconciling `Provider` +packages. Common categories are `composite`, `claim`, `managed`, `packages`, and +`package-runtime`. + +That makes controller metrics a type-level signal: they tell you which kind of +resource its controller is struggling with, not which instance. Pair them with +`kube_customresource_*` or the resources API to get from the type to the +instances. + +List the real values before you write a matcher against them, because the shape +varies by controller: + +```promql +topk(20, sum(increase(controller_runtime_reconcile_total[15m])) by (controller, result)) +``` + +| Question | Ask | +| --- | --- | +| What state is this resource in now? | Resources API. | +| When did it stop being `Ready`? | `kube_customresource_resource_condition` over a time range. | +| Is it flapping? | `changes()` over the same series. | +| What's unhealthy across the fleet? | `kube_customresource_resource_condition == 0`. | +| Is this control-plane-specific or systemic? | Aggregate by `control_plane_name`. | +| Which kind of resource is failing to reconcile? | `controller_runtime_reconcile_errors_total` by `controller`. | + +A worked example. Find which control planes produce the most reconcile errors: + +```promql +topk(5, sum(rate(controller_runtime_reconcile_errors_total[5m])) by (control_plane_name)) +``` + +Take the worst control plane and ask which controller, and so which resource +type, the errors come from: + +```promql +topk(5, sum(rate(controller_runtime_reconcile_errors_total{control_plane_name=""}[5m])) by (controller)) +``` + +Then find the instances of that type that aren't `Ready`: + +```promql +kube_customresource_resource_condition{control_plane_name="", kind="", condition_type="Ready"} == 0 +``` + +Then pull one of those resources from the resources API for its conditions, +messages, and manifest: + +```shell +kubectl --context=hub get --raw \ + '/apis/hub.upbound.io/v1beta1/resources?search=&view=full' +``` + +Working fleet-first like this is the point of the feature. Aggregate to find +where a problem lives, then drop to the resources API for the detail on the one +control plane that has it. + +## Identity and access + +Hub stamps every series with the identity of the control plane it came from, +taken from the authenticated connector rather than from anything the source +collector sends. Write your queries against these two labels: + +| Label | Value | +| --- | --- | +| `control_plane_name` | The control plane's name, unique within its realm. | +| `realm` | The realm that owns the control plane. | + +Because Hub derives them from the authenticated session on arrival, a control +plane can't claim to be another one. The gateway also strips the per-Pod and +per-node attributes the Prometheus receiver adds during scraping, so a restarted +Pod doesn't create a new time series. + +Hub stamps a third label, `control_plane_id`, and uses it to enforce access. +Leave it out of your queries. On every query Hub checks the sender against the +control planes they can access and injects the matching `control_plane_id` +selector. A caller with no authorized control planes gets `403`. + +## What the feature doesn't cover + +- The query API accepts range queries only. There's no instant-query endpoint. +- The pipeline carries metrics only. Logs and traces don't flow through it. +- CPU and memory metrics aren't in the curated set. They need a metrics source + on the control plane that Crossplane doesn't provide out of the box. +- Hub doesn't ship a retention policy. Retention belongs to whichever + Prometheus-compatible backend you point the gateway at. + +## See also + +- [Metrics pipeline](../../../howtos/metrics.md): enable the feature and choose a backend. +- [Connect a control plane](../../../howtos/connect-control-plane.md) +- [Feature flags](../../../reference/feature-flags.md) +- [Feature lifecycle](../../../reference/feature-releases.md) diff --git a/hub-docs/products/insights/overview.md b/hub-docs/products/insights/overview.md new file mode 100644 index 000000000..5d4bee4ce --- /dev/null +++ b/hub-docs/products/insights/overview.md @@ -0,0 +1,277 @@ +--- +title: Insights +sidebar_position: 1 +description: See every resource running across your connected control planes and query them through one Hub API. +--- + +Insights is the resource aggregation layer in Hub. Every connected control plane +syncs its resources into `hub-core`, which serves them through a single API and +the Console. You see and search your whole estate from one place instead of +opening a session on each control plane. + +Insights is new in Hub v3 and reached general availability, so no feature flag +gates it. It's included with Hub at no extra cost in this release. + +:::note +The `AggregatedTypes` gate covers only the fleet-wide type resources that back +[Definitions](definitions.md) and [Packages](packages.md). It defaults to on. +See [Feature flags](../../reference/feature-flags.md#aggregated-types). +::: + +![The aggregated resource list in the Console, with the lens sidebar](/img/hub/insights/resources-all-with-lenses.png) + +## What Insights gives you + +A control plane reports its resources to `hub-core` after you [connect +it][connectCtp]. Insights stores those records and tracks the relationships +between them. It answers queries across every realm the caller has access to. +Each record keeps the resource's own identity in `source`, where it lives in +`location`, and the aggregation metadata in `hub`: + +```json +{ + "kind": "Resource", + "apiVersion": "hub.upbound.io/v1beta1", + "metadata": { + "name": "default.default.78ae25889aa96e82" + }, + "source": { + "apiVersion": "v1", + "kind": "Endpoints", + "name": "kubernetes", + "namespace": "default" + }, + "location": { + "controlPlane": "default", + "realm": "default" + }, + "hub": { + "lastSyncTime": "2026-07-29T15:31:36Z", + "syncLagSeconds": 0, + "relationships": { + "composesCount": 0, + "referencesCount": 0, + "referencedByCount": 0 + } + }, + "status": {} +} +``` + +`syncLagSeconds` tells you how stale a record is. Insights serves an aggregated +view rather than a live read from the control plane. A resource that changed a +moment ago can still show its earlier state. + +## The API + +Insights serves these resources under the `hub.upbound.io` group: + +| Resource | What it returns | +|----------|-----------------| +| `resources` | Lists and gets aggregated resources across control planes. | +| `resources/events` | The Kubernetes events recorded for one resource. | +| `resourcestats` | Counts for a query, grouped by `Ready`, `Synced`, and `Healthy`. | +| `resourcerelationships` | The resources one resource composes, the ones it references, and the ones that reference it. | +| `resourcerelationshiptrees` | The full composition tree below one resource. | +| `lenses` | Saved filters, described in [Lenses](#lenses). | + +The group serves three versions. Use `v1beta1`: + +| Version | Filtering | +|---------|-----------| +| `v1beta1` | Accepts `filter`. | +| `v1alpha2` | Accepts `filter`. Only `resources` and `resources/events`. | +| `v1alpha1` | Ignores `filter` and returns the unfiltered list. | + +### Search and filter a list + +`search` matches resource names and `filter` takes a CEL expression. Both go on +the list endpoint: + +```shell +curl -sG "$HUB_URL/apis/hub.upbound.io/v1beta1/resources" \ + --data-urlencode 'filter=kind == "Pod"' \ + --data-urlencode 'pageSize=25' \ + -H "Authorization: Bearer $TOKEN" +``` + +CEL expressions reference the resource's fields as bare identifiers, such as +`kind` or `namespace`. An invalid expression returns the CEL parse error rather +than an empty list, so read the `message` field when a query returns nothing. + +Every list response reports the match count and the page it returned: + +```json +{ + "kind": "ResourceList", + "apiVersion": "hub.upbound.io/v1beta1", + "metadata": { + "total": { "count": 24, "relation": "eq" }, + "page": 1, + "pageSize": 10 + } +} +``` + +Page through results with `page` and `pageSize`. + +### Count without listing + +`resourcestats` returns the summary the Console dashboard renders. Post an empty +`spec` to count everything the caller can see: + +```shell +curl -s -X POST "$HUB_URL/apis/hub.upbound.io/v1beta1/resourcestats" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"apiVersion":"hub.upbound.io/v1beta1","kind":"ResourceStats","spec":{}}' +``` + +```json +{ + "results": { + "summary": { + "totalCount": 614, + "readyTrue": 27, + "readyFalse": 0, + "readyUnknown": 587, + "syncedTrue": 2, + "syncedFalse": 0, + "syncedUnknown": 612, + "healthyTrue": 4, + "healthyFalse": 0, + "healthyUnknown": 610 + } + } +} +``` + +## Console views + +Insights backs four pages: + +| Page | What it shows | +|------|---------------| +| `/dashboard` | The `resourcestats` summary for your estate. | +| `/explore/resources` | The aggregated resource list, with search, filters, and lenses. | +| `/explore/definitions` | The API types your control planes serve, correlated fleet-wide. See [Definitions](definitions.md). | +| `/explore/packages` | The Crossplane packages your control planes declare, correlated fleet-wide. See [Packages](packages.md). | + +The dashboard reports the `resourcestats` summary alongside control plane and +definition counts: + +![The Hub dashboard showing control plane, resource, and definition counts](/img/hub/insights/dashboard-overview.png) + +Selecting a resource opens a detail drawer. Its tabs show the resource's own +fields and Hub's `location` metadata, the relationships Insights tracked, and +the full object: + +![The resource detail drawer, showing details and location metadata](/img/hub/insights/resource-detail-overview.png) + +![The resource detail drawer, showing tracked relationships](/img/hub/insights/resource-detail-relationships.png) + +The packages view resolves a package's own relationships, including what it +composes and the functions it depends on: + +![A package detail drawer showing composed resources and function dependencies](/img/hub/insights/packages-relationships.png) + +Two of these read separate fleet-wide APIs. `/explore/definitions` and +`/explore/packages` come from the `typedefinitions` and `crossplanepackages` +resources that the alpha `AggregatedTypes` [feature flag][featureFlags] serves. +That gate defaults to `true`, so both pages work on a default install. +[Definitions](definitions.md) and [Packages](packages.md) cover what each view +shows. Enabling [Catalog][catalog] adds a destination alongside +`/explore/packages` rather than replacing it. + +## Lenses + +A lens is a saved filter over a list view. You create one with a display name, a +description, the API it targets, and the fields to match, then apply it in the +Console instead of rebuilding the query. + +In the Console, build up a filter on the resource list and save it. A private +lens belongs to you. A public one is available to your organization: + +![Saving the current filter set as a new lens](/img/hub/insights/lens-save-dialog.png) + +The saved lens then appears in the sidebar and reapplies its filter when +selected: + +![A saved lens applied to the resource list](/img/hub/insights/lens-endpoints-and-services.png) + +Hub includes two lenses. `crossplane-not-ready` finds composite resources whose +`Ready` condition is false, and `packages-unhealthy` finds unhealthy Crossplane +packages. Both carry the `hub.upbound.io/source: upbound` annotation: + +```yaml +apiVersion: hub.upbound.io/v1beta1 +kind: Lens +metadata: + name: crossplane-not-ready + annotations: + hub.upbound.io/shared: "true" + hub.upbound.io/source: upbound +spec: + displayName: Not-ready Crossplane resources + description: Composite resources (XR, claim, composed) where Ready is false. + target: + kind: Resource + apiVersion: hub.upbound.io/v1alpha1 + filter: + fields: + - field: type + values: + - XR + - Claim + - Composed + - field: status + values: + - Ready:False +``` + +Set `hub.upbound.io/shared: "true"` to make a lens available to everyone in the +organization. Without it, the lens belongs to the user who created it. + +## What callers can see + +Insights filters every response by the caller's access. A user sees resources in +the realms they hold permissions for, so two users running the same query get +different counts. See [RBAC][rbac] for how realm permissions map to roles. + +## Typical tasks + +- Find every resource in a failing state across the fleet, without opening a + session on each control plane. +- Trace what a composite resource composes, through + `resourcerelationshiptrees`. +- Report on estate size and health from `resourcestats` instead of counting per + control plane. +- Give a team a starting view of the resources they own, as a shared lens. + +## Related resources + +**How-to guides** + +- [Query your fleet][query] +- [Connect a control plane][connectCtp] +- [Connect a space][connectSpace] +- [Configure RBAC][rbac] + +**Linked concepts** + +- [Hub architecture][architecture] +- [Catalog][catalog] + +**Reference** + +- [Feature flags][featureFlags] +- [Feature lifecycle][featureReleases] + +[architecture]: ../../concepts/architecture.md +[catalog]: ./catalog/overview.md +[connectCtp]: ../../howtos/connect-control-plane.md +[connectSpace]: ../../howtos/connect-space.md +[featureFlags]: ../../reference/feature-flags.md +[featureReleases]: ../../reference/feature-releases.md +[query]: ./resource-exploration/query.md +[rbac]: ../../howtos/rbac.md diff --git a/hub-docs/products/insights/packages.md b/hub-docs/products/insights/packages.md new file mode 100644 index 000000000..be625be13 --- /dev/null +++ b/hub-docs/products/insights/packages.md @@ -0,0 +1,166 @@ +--- +title: Packages +sidebar_position: 4 +description: See which Crossplane packages your control planes declare, at which versions, and where. +--- + +Packages is a fleet-wide list of the Crossplane packages your connected control +planes have declared. Hub correlates each package by its OCI repository, so one +row covers every control plane that declares it and every version they declare. + +:::note +The `AggregatedTypes` gate serves this view and +[Definitions](definitions.md). It defaults to on. See [Feature +flags](../../reference/feature-flags.md#aggregated-types) to turn it off. +::: + +Hub identifies a row by package type and repository, such as the `provider` +`xpkg.upbound.io/upbound/provider-aws`. A single row spans control planes and +versions, so you can see the reach of a package across your fleet at a glance. + +Packages covers all four package types: providers, configurations, functions, +and Upbound AddOns. + +![The Packages list in the Console](/img/hub/packages/packages-list.png) + +## Opening the view + +Open the navigation menu from the header, then choose **Packages** under +**Insights**. The tab strip on the Resources page, the quick access list on the +home page, and the command palette all reach the same view, which lives at +`/explore/packages`. + +Both the menu entry and the tab appear only when the API is available. When the +gate is off, Hub hides them and redirects the page to Resources. + +## The packages list + +Each row is one correlated package. The table shows: + +- **Package Type**. `Provider`, `Configuration`, `Function`, or `AddOn`. +- **Name**. The OCI repository, without a tag or digest. +- **Control Planes**. How many control planes declare the package. +- **Versions**. How many distinct versions the fleet declares. + +Search by repository name, and select a column header to sort. + +A version is the reference a control plane declares the package at, read from +`spec.package` on the package object. Hub records the OCI tag, such as +`v1.24.0`. When a control plane pins the package by digest alone, Hub records +that digest, such as `sha256:5f3c…`, as the version. A reference carrying both +a tag and a digest counts under its tag. + +A version count above one means control planes declare the package under more +than one distinct reference. The same image counts twice when one control plane +pins it by tag and another pins it by digest. + +:::note +Packages reflects what a control plane declares, not what installed +successfully. A package appears here as soon as its object exists, even when the +image never pulled or the revision never became healthy. Open the object from +the **Source** column in the detail drawer to check its install status. +::: + +## Filtering the list + +A filter bar sits above the table. Choose **Filter** to see the filters this +table offers, then pick one to add it to the bar: + +- **Package Type**. One or more of Provider, Configuration, Function, and AddOn. +- **Control Plane**. One or more control planes. + +Each filter you add becomes a pill on the bar, labeled with its name and the +current selection. Selecting a pill opens its value list, where both filters +accept several values at once. Long lists have a search box. Remove one filter +with the **×** on its pill, or clear every filter with **Clear All**. + +Filters combine, so a bar holding both narrows to the packages of those types +declared on those control planes. To filter from the table instead, right-click +a cell and choose **Filter by this value**. + +![The Packages filter bar with a value list open](/img/hub/packages/packages-filters.png) + +## Package details + +Select a row to open a detail drawer. The heading shows the repository and the +package type, and the **Versions** tab lists one entry for every version and +control plane pairing: + +- **Version**. The tag or digest that control plane declares. +- **Control Plane**. The control plane that declares this version. +- **Realm**. The realm the control plane belongs to. Selecting it opens the + realm. +- **Source**. The name of the `Provider`, `Configuration`, `Function`, or + `AddOn` object on that control plane. Selecting it opens that live resource. +- **Package Ref**. The full package reference as declared, including the tag or + digest. + +Filter the table by version to see which control planes pin a particular +release. + +![The Versions tab of the package detail drawer](/img/hub/packages/package-versions.png) + +## Access and counts + +Counts reflect your access. Hub correlates a package across the whole +organization, but the control plane count, the version count, and the rows on +the Versions tab only include control planes in realms you can reach. + +:::warning +Unless you have access to every control plane in the organization, the counts +are partial. A package that looks consistent at one version may sit at another +version on control planes you can't view. See [Access and +authorization](/hub/howtos/rbac). +::: + +## Common tasks + +- Inventory every provider, configuration, function, and AddOn your fleet + declares, without listing package objects on each control plane. +- Find version skew by sorting on the version count, then confirm which control + planes lag on the Versions tab. +- Track a rollout by watching a package's version count return to one. +- Find which control planes pin a package by digest rather than a tag. + +## Going further + +Packages answers which control planes declare a package, and at which version. +It reports on the package objects on your control planes, not on the contents of +the package images. + +[Catalog](catalog/overview.md) expands on the same idea by indexing the images +themselves. Catalog records the resource types each image declares, serves their +OpenAPI schemas, and searches across indexed images and their descriptions, so +you can inspect an API without installing the package. + +:::note +Catalog is a preview feature and, unlike Packages, it's disabled by default. See +[Feature flags](../../reference/feature-flags.md#catalog) to turn it on. +::: + +Enabling Catalog doesn't replace Packages. Catalog appears as an extra +destination alongside it. + +## Querying the API + +The Console reads the `crossplanepackages` resource in the +`hub.upbound.io/v1alpha1` API group: + +- `crossplanepackages`. Lists and gets correlated packages. Filter a list by + `packageType` and `controlPlane`. +- The `distribution` subresource. Per-version detail for one package: each + version, and the control planes, realms, source objects, and package + references behind it. + +## See also + +**Features** + +- [Insights](overview.md) +- [Definitions](definitions.md) +- [Catalog](catalog/overview.md) + +**Reference** + +- [Feature flags](../../reference/feature-flags.md) +- [Feature lifecycle](../../reference/feature-releases.md) diff --git a/hub-docs/products/insights/resource-exploration/filtering-resources.md b/hub-docs/products/insights/resource-exploration/filtering-resources.md new file mode 100644 index 000000000..3c1f7fc21 --- /dev/null +++ b/hub-docs/products/insights/resource-exploration/filtering-resources.md @@ -0,0 +1,198 @@ +--- +title: Filtering resource lists +sidebar_position: 3 +description: Answer fleet-wide questions with filter expressions, from a single condition to combined ones. +--- + +This guide covers three fleet-wide queries: finding unhealthy resources across +every control plane, scoping a list to one realm, and combining conditions that +no single query parameter covers. + +Every example runs against the `v1beta1` resources endpoint. See the [Resource +filter expressions overview](overview.md) for the full field list. + +## Before you start + +The examples use a `hub` kubectl context. See [Configure kubectl for the +hub](../../../howtos/configure-kubectl.md) to set one up. + +`filter` values need URL encoding, which makes long expressions hard to read on +a command line. Define a helper that encodes for you: + +```shell +hubfilter() { + kubectl --context=hub get --raw \ + "/apis/hub.upbound.io/v1beta1/resources?filter=$(jq -rn --arg f "$1" '$f|@uri')" +} +``` + +Then pass expressions as you wrote them: + +```shell +hubfilter 'kind == "XApp"' | jq -r '.items[].metadata.name' +``` + +## Match a single field + +The simplest filters compare one field: + +```text +kind == "RDSInstance" +namespace == "team-alpha" +apiVersion == "v1beta1" +crossplaneType in ["xr", "mr"] +``` + +`in` takes a list, which is shorter than chaining `||`: + +```text +kind in ["RDSInstance", "S3Bucket", "VPC"] +``` + +## Find unhealthy resources + +Each of `ready`, `synced`, and `healthy` exposes a `status` and a `message`: + +```text +conditions.ready.status == "False" +conditions.healthy.status == "False" +conditions.ready.status == "False" || conditions.synced.status == "False" +``` + +`!=` and `!` both work, and they differ in how they treat `Unknown`. Use `!=` to +catch resources that are neither ready nor reporting: + +```text +conditions.ready.status != "True" +!(conditions.ready.status == "True") +``` + +## Scope to part of the fleet + +`controlPlane`, `realm`, and `space` narrow a query to one slice of the fleet. +Hub pushes these down into its authorization query, so they're the cheapest +predicates to add: + +```text +realm == "production" +realm in ["prod-us", "prod-eu"] +controlPlane == "prod-west" +``` + +Combined with a condition, this is the "what's broken in production" query: + +```shell +hubfilter 'conditions.ready.status == "False" && realm == "production"' \ + | jq -r '.items[] | "\(.metadata.name)\t\(.kind)"' +``` + +## Match on names and labels + +String functions cover prefix, suffix, partial match, and regex: + +```text +name.startsWith("api-") +name.endsWith("-prod") +name.contains("payments") +name.matches("^web-[0-9]+$") +``` + +Labels and annotations are maps. Index them by key, or test for a key with `in`: + +```text +labels["team"] == "alpha" +"team" in labels +labels["team"] == "platform" && !(annotations["deprecated"] == "true") +``` + +:::note +A regex passed to `matches()` has a limit of 1000 characters. +::: + +## Filter by time + +`createdAt`, `updatedAt`, and `deletedAt` are timestamps. Compare them to an +absolute time with `timestamp()`, or to a relative one with `now()` and +`duration()`: + +```text +updatedAt > timestamp("2026-01-15T00:00:00Z") +createdAt > now() - duration("24h") +updatedAt < deletedAt +``` + +A created-between window is two comparisons: + +```text +namespace == "team-alpha" && + createdAt > timestamp("2026-01-01T00:00:00Z") && + createdAt < timestamp("2026-02-01T00:00:00Z") +``` + +## Combine conditions + +Parentheses group sub-expressions and control precedence: + +```text +(conditions.ready.status == "False" || conditions.synced.status == "False") && + realm == "production" +``` + +Recently changed AWS resources, either created or updated: + +```text +group.contains("aws") && + (createdAt > now() - duration("24h") || updatedAt > now() - duration("1h")) +``` + +A full fleet triage query, scoped and named and timed at once: + +```text +controlPlane == "prod-west" && + (kind == "Pod" || kind == "Deployment") && + labels["tier"] == "frontend" && + createdAt > now() - duration("168h") +``` + +## Page through matches + +Filtering runs in the database before pagination, so `page` and `pageSize` walk +the matched set and `metadata.total` is the filtered count: + +```shell +kubectl --context=hub get --raw \ + '/apis/hub.upbound.io/v1beta1/resources?pageSize=50&page=2&filter=realm%3D%3D%22production%22' \ + | jq '{total: .metadata.total, returned: (.items | length)}' +``` + +Pages count from 1. + +## Errors + +An invalid expression returns `400` and doesn't reach the database. The message +tells you which part failed: + + + + +| Message | Cause | +| --- | --- | +| `unknown column ""` | The identifier isn't in the field list. Check [the available fields](overview.md#available-fields). | +| `unsupported operator or function ""` | The function isn't supported. Macros such as `exists`, `all`, and `map` are rejected. | +| `filter must evaluate to a boolean condition, got ` | The expression returns a value rather than a comparison, such as `kind` on its own. | +| `regex pattern exceeds 1000 characters` | Shorten the `matches()` pattern. | +| `invalid duration ""` | `duration()` takes a Go duration string, such as `24h` or `90m`. | +| `invalid timestamp "": must be RFC3339` | `timestamp()` takes an RFC 3339 value, such as `2026-01-15T00:00:00Z`. | +| A CEL parser error with a source location | A syntax or type error. The message points at the offending column. | + + + + +A `400` with no message about the filter, on a request you expected to work, is +usually the wrong API version. `v1alpha1` ignores `filter` rather than +evaluating it. See [Where you can use it](overview.md#where-you-can-use-it). + +## See also + +- [Resource filter expressions overview](overview.md) +- [Connect a control plane](../../../howtos/connect-control-plane.md) diff --git a/hub-docs/products/insights/resource-exploration/overview.md b/hub-docs/products/insights/resource-exploration/overview.md new file mode 100644 index 000000000..bc54b905d --- /dev/null +++ b/hub-docs/products/insights/resource-exploration/overview.md @@ -0,0 +1,112 @@ +--- +title: Resource filtering +sidebar_position: 1 +description: Filter fleet-wide resource lists with CEL expressions evaluated in the database. +--- + +Resource filter expressions let you narrow a fleet-wide list with a +[CEL](https://github.com/google/cel-spec) expression instead of a fixed set of +query parameters. You pass the expression in the `filter` query parameter and +Hub compiles it to SQL, so filtering happens in the database rather than in your +client. + +```text +conditions.ready.status == "False" && createdAt > now() - duration("24h") +``` + +:::note +Resource filter expressions is an alpha feature. Its grammar and available +fields may change in incompatible ways between releases. See the [feature +lifecycle](../../../reference/feature-releases.md). +::: + +## Where you can use it + +| Endpoint | Notes | +| --- | --- | +| `GET /apis/hub.upbound.io/v1beta1/resources` | Current version. | +| `GET /apis/hub.upbound.io/v1alpha2/resources` | Same filter schema as `v1beta1`. | +| `GET /apis/catalog.hub.upbound.io/v1alpha1/images` | Different field set. Requires the `Catalog` gate. | +| `POST /apis/catalog.hub.upbound.io/v1alpha1/imagesearches` | Takes the expression in `spec.filter`. Requires the `Catalog` gate. | + +`hub.upbound.io/v1alpha1/resources` doesn't accept `filter` at all. It takes +discrete per-field parameters instead, such as `kind`, `controlPlane`, `ready`, +and `labelSelector`. It ignores a `filter` key without an error. Use `v1beta1` +for expression filtering. + +## Available fields + +You can use these fields in a filter expression on the resources endpoints: + +| Field | Type | Notes | +| --- | --- | --- | +| `kind` | string | | +| `group` | string | | +| `apiVersion` | string | The version segment only, such as `v1beta1`. Not `group/version`. | +| `name` | string | | +| `namespace` | string | | +| `labels` | map(string, string) | | +| `annotations` | map(string, string) | | +| `controlPlane` | string | | +| `realm` | string | | +| `space` | string | | +| `crossplaneType` | string | One of `xr`, `mr`, `composed`, `claim`. | +| `conditions.ready.status` | string | `True`, `False`, or `Unknown`. | +| `conditions.ready.message` | string | | +| `conditions.synced.status` | string | | +| `conditions.synced.message` | string | | +| `conditions.healthy.status` | string | | +| `conditions.healthy.message` | string | | +| `createdAt` | timestamp | | +| `updatedAt` | timestamp | | +| `deletedAt` | timestamp | | + +There's no `status` field and no free-form access to the resource object. An +unknown identifier is an error, not an empty match. + +## Supported operators and functions + +| Category | Supported | +| --- | --- | +| Boolean | `&&`, `\|\|`, `!` | +| Comparison | `==`, `!=`, `<`, `<=`, `>`, `>=` | +| Membership | `in` | +| String | `startsWith`, `endsWith`, `contains`, `matches` | +| Conversion | `timestamp()`, `duration()`, `int()`, `string()` | +| Time | `now()` | + + +The API rejects anything outside this list. That includes CEL macros such as +`exists`, `all`, `map`, and `filter`, ternary expressions, and arithmetic beyond +timestamp and duration math. The expression as a whole must evaluate to a +boolean. + + +## How filtering behaves + +Hub compiles the expression to a SQL `WHERE` clause and applies it before +pagination, so `page` and `pageSize` page through the matches. The `total` in +the response metadata is the filtered count, not the fleet count. + + +Filtering never widens access. The expression is evaluated against the resources +the caller is already authorized to see, so a filter can't surface a resource in +a realm the user can't view. + + +## Limits + +| Limit | Value | +| --- | --- | +| Expression nesting depth | 50 | +| Regex pattern length in `matches()` | 1000 characters | + + +There's no cap on overall expression length. + + +## See also + +- [Filtering resource lists](filtering-resources.md) +- [Feature flags](../../../reference/feature-flags.md) +- [Feature lifecycle](../../../reference/feature-releases.md) diff --git a/hub-docs/products/insights/resource-exploration/query.md b/hub-docs/products/insights/resource-exploration/query.md new file mode 100644 index 000000000..386cd9d39 --- /dev/null +++ b/hub-docs/products/insights/resource-exploration/query.md @@ -0,0 +1,274 @@ +--- +title: Query your fleet +sidebar_position: 2 +description: Search, filter, and count resources across your connected control planes with the Insights API, and save a query as a lens. +--- + +This guide explains how to query the resources Hub aggregates from your +connected control planes. It also covers inspecting a single resource in detail +and saving a query as a lens your team can reuse. + +## Prerequisites + +- A Hub install with at least one [connected control plane][connectCtp] or + [connected space][connectSpace]. +- The base URL clients use to reach `hub-core`, the same value as + `hub-core.api.externalURL`. +- A Hub access token for an account with read access to at least one realm. + +The examples use two shell variables: + +```shell +HUB_URL=https://api. +TOKEN= +``` + +## Step 1: List the aggregated resources + +1. Request the resource list. + + ```shell + curl -s "$HUB_URL/apis/hub.upbound.io/v1beta1/resources" \ + -H "Authorization: Bearer $TOKEN" + ``` + + Each item carries the resource's own identity in `source`, its control plane + and realm in `location`, and the aggregation metadata in `hub`. + +2. Read the count from the response metadata. + + ```json + { + "kind": "ResourceList", + "apiVersion": "hub.upbound.io/v1beta1", + "metadata": { + "total": { "count": 614, "relation": "eq" }, + "page": 1, + "pageSize": 10 + } + } + ``` + + `total.count` is the number of resources matching the query, not the number + returned. Page through the matches with `page` and `pageSize`. + + The Console renders the same list at `/explore/resources`: + + ![The unfiltered aggregated resource list in the Console](/img/hub/insights/resources-list.png) + +:::warning +Use `hub.upbound.io/v1beta1`. The `v1alpha1` version accepts the `filter` +parameter but ignores it, so a filtered query sent to `v1alpha1` returns your +whole estate. +::: + +## Step 2: Narrow the list + +1. Search by name with `search`. + + ```shell + curl -sG "$HUB_URL/apis/hub.upbound.io/v1beta1/resources" \ + --data-urlencode 'search=kubernetes' \ + -H "Authorization: Bearer $TOKEN" + ``` + +2. Filter with a CEL expression in `filter`. + + ```shell + curl -sG "$HUB_URL/apis/hub.upbound.io/v1beta1/resources" \ + --data-urlencode 'filter=kind == "Pod"' \ + --data-urlencode 'pageSize=25' \ + -H "Authorization: Bearer $TOKEN" + ``` + + Reference resource fields as bare identifiers, such as `kind` or `namespace`. + + The Console builds the same narrowing through **Add Filter**, which lists the + fields you can filter on: + + ![The Add Filter menu listing filterable resource fields](/img/hub/insights/resources-add-filter-menu.png) + + An applied filter shows as a chip over the list, with the match count + updated: + + ![The resource list narrowed by a Kind filter](/img/hub/insights/resources-filter-kind-applied.png) + +3. Check `message` if a query returns no items. + + An invalid expression returns the CEL parse error rather than an empty + result: + + ```json + { + "message": "ERROR: :1:1: undeclared reference to 'source'\n | source.kind == \"Pod\"\n | ^" + } + ``` + +## Step 3: Inspect one resource + +1. Get the resource by the name Hub assigned it. + + The name in `metadata.name` is Hub's identifier, such as + `default.default.eb8a0fbd0b83f39b`, not the resource's own name. + + ```shell + curl -s "$HUB_URL/apis/hub.upbound.io/v1beta1/resources/$NAME" \ + -H "Authorization: Bearer $TOKEN" + ``` + +2. Read its events. + + ```shell + curl -s "$HUB_URL/apis/hub.upbound.io/v1beta1/resources/$NAME/events" \ + -H "Authorization: Bearer $TOKEN" + ``` + +3. Follow its relationships. + + ```shell + curl -s "$HUB_URL/apis/hub.upbound.io/v1beta1/resourcerelationships/$NAME" \ + -H "Authorization: Bearer $TOKEN" + ``` + + `resourcerelationships` returns the resources one resource composes, the ones + it references, and the ones that reference it. For the whole composition tree + below a composite resource, request `resourcerelationshiptrees/$NAME` instead. + Both endpoints answer for a single resource, so a request without a name + fails. + + The Console shows all three in one detail drawer, with the object itself on + the **YAML** tab: + + ![The resource detail drawer showing the resource YAML](/img/hub/insights/resource-detail-yaml.png) + +## Step 4: Count without listing + +Post an empty `spec` to `resourcestats` to count everything the caller can see. + +```shell +curl -s -X POST "$HUB_URL/apis/hub.upbound.io/v1beta1/resourcestats" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"apiVersion":"hub.upbound.io/v1beta1","kind":"ResourceStats","spec":{}}' +``` + +The response groups the count by condition, which is what the Console dashboard +renders: + +```json +{ + "results": { + "summary": { + "totalCount": 614, + "readyTrue": 27, + "readyFalse": 0, + "readyUnknown": 587, + "syncedTrue": 2, + "syncedFalse": 0, + "syncedUnknown": 612, + "healthyTrue": 4, + "healthyFalse": 0, + "healthyUnknown": 610 + } + } +} +``` + +A high `readyUnknown` count is expected. Kubernetes resources that don't report a +`Ready` condition, such as `Endpoints`, count as unknown. + + +## Step 5: Save the query as a lens + +A lens stores a filter so you can apply it in the Console instead of rebuilding +the query. + +1. Create the lens. + + ```shell + curl -s -X POST "$HUB_URL/apis/hub.upbound.io/v1beta1/lenses" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "apiVersion": "hub.upbound.io/v1beta1", + "kind": "Lens", + "metadata": { + "name": "team-a-pods", + "annotations": { "hub.upbound.io/shared": "true" } + }, + "spec": { + "displayName": "Team A pods", + "description": "Pods the platform team owns.", + "target": { "kind": "Resource", "apiVersion": "hub.upbound.io/v1beta1" }, + "filter": { "fields": [ { "field": "kind", "values": ["Pod"] } ] } + } + }' + ``` + + Hub records who created the lens in its status: + + ```json + { + "status": { + "lastUpdatedAt": "2026-07-29T16:50:20Z", + "createdBy": "keycloak:admin@hub.demo", + "lastUpdatedBy": "keycloak:admin@hub.demo" + } + } + ``` + + Set `hub.upbound.io/shared: "true"` to give the whole organization access to + the lens. Without it, the lens belongs to you. + +2. Confirm the lens exists. + + ```shell + curl -s "$HUB_URL/apis/hub.upbound.io/v1beta1/lenses" \ + -H "Authorization: Bearer $TOKEN" + ``` + + Your lens appears alongside the two lenses Hub includes, + `crossplane-not-ready` and `packages-unhealthy`. Those two carry the + `hub.upbound.io/source: upbound` annotation. + +3. Apply the lens in the Console, on the `/explore/resources` page. + + The sidebar groups the lenses you created separately from the shared ones and + the ones Hub includes: + + + ![A saved lens selected in the Console sidebar](/img/hub/insights/lens-endpoints-and-services.png) + +4. Delete the lens when the team no longer needs it. + + ```shell + curl -s -X DELETE "$HUB_URL/apis/hub.upbound.io/v1beta1/lenses/team-a-pods" \ + -H "Authorization: Bearer $TOKEN" + ``` + + A successful delete returns `204` with no body. + +## Troubleshoot + +A query that returns fewer resources than you expect usually means one of three +things: + +- **The caller lacks access.** Insights filters every response by realm + permissions, so two users running one query get different counts. See + [RBAC][rbac]. +- **The version ignores your filter.** `v1alpha1` accepts `filter` and ignores + it. Use `v1beta1`. +- **The record is stale.** Insights serves aggregated data, not a live read. + Check `hub.syncLagSeconds` on the resource to see how far behind the record + fell. + +## See also + +- [Insights][insights] +- [Connect a control plane][connectCtp] +- [Configure RBAC][rbac] + +[connectCtp]: ../../../howtos/connect-control-plane.md +[connectSpace]: ../../../howtos/connect-space.md +[insights]: ./overview.md +[rbac]: ../../../howtos/rbac.md diff --git a/hub-docs/reference/_category_.json b/hub-docs/reference/_category_.json new file mode 100644 index 000000000..f518ad576 --- /dev/null +++ b/hub-docs/reference/_category_.json @@ -0,0 +1,5 @@ +{ + "label": "Reference", + "position": 4, + "collapsed": true +} diff --git a/hub-docs/reference/feature-flags.md b/hub-docs/reference/feature-flags.md new file mode 100644 index 000000000..c39553e33 --- /dev/null +++ b/hub-docs/reference/feature-flags.md @@ -0,0 +1,145 @@ +--- +title: Feature flags +sidebar_position: 2 +description: Enable optional Hub features through Helm values. +--- + +Hub keeps several features behind feature flags. Each flagged feature is at +either the alpha or beta stage of the [feature lifecycle][feature-releases], +which sets its default state: + +- **Alpha** features are disabled by default. You opt in explicitly. +- **Beta** features are enabled by default. The flag stays available so you can + turn the feature off. + +Once a feature reaches general availability its flag is removed and the feature +is always on, so GA features don't appear here. See the [feature +lifecycle][feature-releases] for the stability, support, and +API-compatibility expectations at each stage. + +## How feature flags work + +Each gate has a built-in default in `hub-core`'s application code, and the +chart only passes your overrides. The values under +`hub-core.api.featureFlags.gates.*` render to a sorted, comma-separated +`FEATURE_GATES` environment variable on the `hub-core` Deployment, such as +`Catalog=true,Registry=true`. When you override nothing, the chart omits the +variable and the built-in defaults stand. + +`hub-core` builds its flag definition in memory from those gates and serves it +over [OFREP][ofrep]. No ConfigMap holds the definition, so you toggle features +through Helm values rather than editing flag definitions by hand. Each gate is +named after the capability it controls, as a single PascalCase token such as +`Catalog` or `Registry`. + +An unrecognized gate name fails `hub-core` startup rather than being ignored. + +`hub-core.api.featureFlags.enabled` controls the flag server itself, +which defaults to `true`. Leave it on. When it's `false`, `hub-core` starts with +no flag client and every gated feature is forced off regardless of the +`gates.*` values. + +## Available feature flags + +Set a gate with `hub-core.api.featureFlags.gates.`; drop the leading +`hub-core.` if you install the `hub-core` subchart on its own. The gate name is +also the name that appears in `FEATURE_GATES` and in the `hub-core` startup +logs. Every gate in this release is alpha, and most default to `false`. + + +| Gate | Default | What it enables | +|------|---------|-----------------| +| `AggregatedTypes` | `true` | Fleet-wide `typedefinitions` and `crossplanepackages`, and their distribution subresources, under `hub.upbound.io/v1alpha1`. | +| `Catalog` | `false` | The Catalog feature as a unit: the read API (`catalog.hub.upbound.io/v1alpha1`) covering Image list and get, usage, curated, OpenAPI subresources, and ImageSearch, plus the ingest and enrichment pipeline that populates it. | +| `Metrics` | `false` | The metrics ingest endpoint and the `metrics.hub.upbound.io` API group. Requires `hub-core.otelGateway.enabled=true`. | +| `Registry` | `false` | The `registry.hub.upbound.io` API group, providing the `Connection` resource (with its `verify` subresource) and the `Repository` resource. | + + + + + + + + + + + +### Aggregated types + +The `AggregatedTypes` gate serves the fleet-wide `typedefinitions` and +`crossplanepackages` resources, along with their `distribution` subresources. It +defaults to `true` because the [Definitions](../products/insights/definitions.md) +and [Packages](../products/insights/packages.md) views in the Console read those +APIs. Setting it to `false` hides both views and drops both resources from +`hub.upbound.io/v1alpha1` discovery. The rest of the group keeps working. + +### Catalog + +The `Catalog` gate turns the feature on as a unit: the read API and the ingest +and enrichment pipeline that populates it move together behind the one gate. See +[Catalog](../products/insights/catalog/overview.md) for what the feature does. + +### Metrics + +The `Metrics` gate turns on the query API and the endpoint connectors push to. +It also needs the components that carry the pipeline: set +`hub-core.otelGateway.enabled=true` and a backend, or `hub-core` refuses to +start. Enabling the gate collects nothing until you also turn on the collector +in the `hub-connector` chart. See [Metrics](../products/insights/metrics/overview.md) and +[Metrics pipeline](../howtos/metrics.md). + +### Registry + +The `Registry` gate supplies the credentials Catalog uses to pull from private +or self-hosted registries. See [Registry](../products/insights/registry/overview.md). + +## Enabling a feature gate + +Gates go in the same `values.yaml` you installed with, and a `helm upgrade` +applies them. The `hub-core` startup logs report the resolved value of every +gate, so check them to confirm which gates the running binary picked up. + +```yaml title="values.yaml" +hub-core: + api: + featureFlags: + gates: + Catalog: true + Registry: true + Metrics: true + + # Required whenever the Metrics gate is on. + otelGateway: + enabled: true + metrics: + backend: prometheus + queryURL: http://prometheus-server.monitoring.svc + prometheus: + writeEndpoint: http://prometheus-server.monitoring.svc/api/v1/write +``` + +Disabling a beta feature works the same way in reverse. Set its gate to `false` +to turn off a feature that defaults to on. + +## Managing flags outside the chart + +For teams that manage flag definitions themselves: + +- **Targeting overlay.** Set + `hub-core.api.featureFlags.targetingOverlay.configMapRef.name` to a ConfigMap + you maintain, holding a flagd `flags.json` under the key named by + `targetingOverlay.configMapRef.key` (default `flags.json`). The overlay doesn't + replace the gates. It layers JSONLogic targeting rules or extra variants on top + of them, which is how you roll a feature out per organization or per control + plane rather than fleet-wide. `hub-core` hot-reloads the ConfigMap, so edits + take effect without restarting the Pods. +- **OFREP endpoint.** The flag server serves OFREP on port `8016` for + client-side evaluation. It's not exposed publicly by default. To route it + through the public HTTPRoute, set + `hub-core.api.featureFlags.httpRoute.enabled=true`, which mounts it at + `/ofrep`. + + + +[feature-releases]: /hub/reference/feature-releases +[ofrep]: https://openfeature.dev/specification/appendix-c/ diff --git a/hub-docs/reference/feature-releases.md b/hub-docs/reference/feature-releases.md new file mode 100644 index 000000000..7cfa7c217 --- /dev/null +++ b/hub-docs/reference/feature-releases.md @@ -0,0 +1,59 @@ +--- +title: Feature lifecycle +sidebar_position: 3 +description: Alpha, beta, and GA feature stages and the Hub release cycle. +--- + +## Feature stages + +A feature in Hub follows the same lifecycle as [upstream +Kubernetes][upstream-kubernetes]. + +An _Alpha_ feature represents a brand new feature that's still under active +development and has no commitment from the project to be completed. This means: + +- Disabled by default (behind a feature flag) +- Might be buggy or have reduced performance +- Support for the feature may be dropped at any time without notice +- The API may change in incompatible ways in a later release without notice +- Recommended for use only in short-lived testing clusters + +A _Beta_ feature represents a feature that has been promoted from _Alpha_, but +is still under active development. The project has committed to completing the +feature in some form. This means: + + +- Usually enabled by default (can be disabled with a feature flag) +- The feature is well tested and generally considered safe +- Support for the feature isn't dropped, details and exact API may + change +- The schema and/or semantics may change in incompatible ways in later beta + or stable release. Instructions are provided for migrating to the next + version but may require deleting, editing or re-creating objects which may + require downtime for applications relying on the feature. +- Recommended for only non-business-critical uses because of incompatible + changes. + + +A _General Availability (GA)_ feature represents a stable feature. This means: + +- The feature is always enabled (and can't be disabled) +- The corresponding feature flag has been removed +- Stable versions of features appear in released software for many + later versions + +For a more detailed breakdown of reliability, upgradeability and completeness +see the [Kubernetes SIG Architecture +document][kubernetes-sig-architecture-document]. +Please note that Upbound doesn't commit to the same support guarantees and/or +release cycles of upstream Kubernetes. + +## Release cycle + +Hub currently releases updates on a quarterly (13 week) cadence. Upbound +maintains support the past 12 months of releases. + + + +[kubernetes-sig-architecture-document]: https://github.com/kubernetes/community/blob/main/contributors/devel/sig-architecture/api_changes.md#alpha-beta-and-stable-versions +[upstream-kubernetes]: https://kubernetes.io/docs/reference/command-line-tools-reference/feature-gates/#feature-stages diff --git a/hub-docs/reference/helm-values.md b/hub-docs/reference/helm-values.md new file mode 100644 index 000000000..75d86868a --- /dev/null +++ b/hub-docs/reference/helm-values.md @@ -0,0 +1,222 @@ +--- +title: Helm values +sidebar_label: Helm values +description: Configuration reference for the Hub Helm charts, generated from each chart's values. +sidebar_position: 3 +--- + +The tables below list every configurable value in the Hub Helm charts, generated +from the charts themselves. + +Hub installs through the umbrella **`hub`** chart, which pulls in the +**`hub-core`** and **`hub-connector`** subcharts. When configuring through the +umbrella, set a subchart value under its key, like `hub-core.api.replicaCount` +or `hub-connector.connector.replicaCount`. The `hub-webui` component is distributed +as a separate OCI chart and is not covered here. + +## `hub` chart + +Umbrella chart that deploys the full Hub stack — Hub Core, Hub Connector, and Hub WebUI. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| global.apiServiceName | string | `"hub-core"` | Name of the hub-core Service. Changing this one value rewires the connector and webui to the API automatically. | +| global.apiServicePort | int | `8080` | Port the hub-core Service listens on. | +| global.demo | object | (see fields below) | Install an opinionated, self-contained demo of the hub stack on the current Kubernetes cluster. When enabled, the chart also bundles PostgreSQL, Keycloak, and the Envoy Gateway controller, wires hub-core and hub-connector to them, and auto-registers the cluster as a default ControlPlane. The result is a complete stack that a prospect can browse without any external prerequisites. Demo only - fixed credentials, ephemeral storage, self-signed TLS. Not suitable for production. For production, leave demo.enabled false and supply your own PostgreSQL connection, identity providers, and ControlPlanes via hub-core.bootstrap.files. See the user documentation for more details. | +| global.demo.envoy | object | `{"nodePorts":{"https":30443,"postgres":30432},"serviceType":"NodePort"}` | Bundled Envoy Gateway data plane. NodePorts must match the host port mappings on the KIND cluster. | +| global.demo.keycloak.image | object | (see fields below) | Image used for the bundled demo Keycloak. | +| global.demo.keycloak.resources | object | `{"limits":{"cpu":"1000m","memory":"1Gi"},"requests":{"cpu":"100m","memory":"512Mi"}}` | Resource requests and limits for the Keycloak container. | +| global.demo.postgres.image | object | (see fields below) | Image used for the bundled demo PostgreSQL. | +| global.demo.postgres.resources | object | `{"limits":{"cpu":"500m","memory":"512Mi"},"requests":{"cpu":"50m","memory":"128Mi"}}` | Resource requests and limits for the Postgres container. | +| global.demo.postgres.tcpRoute | object | `{"enabled":true}` | Requires gateway.listeners.postgres.enabled. | +| global.gateway | object | (see fields below) | Kubernetes Gateway API integration. Subchart HTTPRoutes gate on enabled. Forcefully enabled by demo mode. | +| global.gateway.domain | string | `""` | Apex domain. <subdomain>.<domain> composes per-service hostnames. When demo.enabled, this defaults to 127.0.0.1.nip.io. | +| global.gateway.gatewayClassName | string | `""` | Required when create=true. Identifies the GatewayClass that the cluster's Gateway controller has registered (e.g. "envoy", "istio", "cilium"). | +| global.gateway.listeners.https.tls.certificateRefs | list | `[]` | Leave empty to offload TLS to the upstream (e.g. ACM on a cloud load balancer). Set refs to decrypt TLS inside the cluster. | +| global.gateway.namespace | string | `""` | Default = release namespace. | +| global.gateway.parentRef | object | `{"name":"hub-gateway","namespace":"","sectionName":""}` | Used when create=false. Charts target this parentRef. | +| global.gateway.subdomains | object | `{"api":"api","auth":"auth","db":"db","ui":"ui"}` | Subdomain prefixes per service. Each subchart can override these locally via its own httpRoute.subdomain. | +| global.hubCoreExternalUrl | string | `""` | Externally reachable base URL for hub-core. Used by hub-webui to redirect the browser through the OIDC login flow, and by hub-core to construct OIDC callback URIs. Precedence: explicit value > hub-core.api.externalURL > gateway-derived (<scheme>://<gateway.subdomains.api>.<gateway.domain> when gateway integration is on). Empty for production installs without a gateway. | +| global.spaces | object | `{"enabled":false}` | Spaces integration. When enabled, the connector registers as a space instead of a control plane, and (in demo mode) hub-core bootstraps a Space for it to register against. | +| hub-connector.connector.fullnameOverride | string | `"hub-connector"` | | +| hub-core | object | (see fields below) | PostgreSQL connection must be provided at install time. Either set connectionString or host + auth credentials. | + +## `hub-core` chart + +REST/Kubernetes-style API that stores and serves resources. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| api.affinity | object | `{}` | | +| api.args | list | `[]` | Additional command line arguments | +| api.autoscaling | object | `{"enabled":false,"maxReplicas":100,"minReplicas":1,"targetCPUUtilizationPercentage":80}` | This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/ | +| api.cors.allowedOrigins | list | `[]` | If empty, all origins are allowed | +| api.cors.enabled | bool | `false` | Auto-enabled (with the WebUI origin injected) when global.gateway.enabled, since the WebUI and API are routed to different subdomains. | +| api.externalURL | string | `""` | Externally reachable base URL used to construct the OIDC callback URI (e.g. https://api.example.com). Required when identity providers are configured; the callback will be: <externalURL>/oidc/callback | +| api.extraEnv | list | `[]` | Additional environment variables | +| api.extraInitContainers | list | `[]` | Additional init containers | +| api.extraVolumeMounts | list | `[]` | Additional volume mounts for the main container | +| api.extraVolumes | list | `[]` | Additional volumes | +| api.featureFlags | object | (see fields below) | A built-in feature flag OFREP HTTP endpoint. When enabled, hub-core builds its flag definition in memory from the gates it ships (their defaults live in application code) and serves it over OFREP. Use gates below to override a gate's built-in default, and targetingOverlay to add rollout rules. | +| api.featureFlags.gates | object | `{}` | Feature gate overrides. Each gate ships a built-in default in application code; list a gate here only to override it, e.g. `Catalog: true`. Unknown or locked gates fail hub-core startup. Rendered to --feature-gates. | +| api.featureFlags.httpRoute | object | `{"enabled":false,"pathPrefix":"/ofrep"}` | Expose OFREP through the public HTTPRoute at /ofrep. Keep disabled by default; OFREP is intended for client-side flag evaluation only when explicitly exposed. | +| api.featureFlags.targetingOverlay | object | `{"configMapRef":{"key":"flags.json","name":""}}` | Optional targeting overlay: a ConfigMap holding a flagd flags.json that adds JSONLogic targeting rules or extra variants on top of the generated gate definition (for per-organization / per-control-plane rollout). It is hot-reloaded, so edits take effect without a pod restart. | +| api.frontendURL | string | `""` | Post-login redirect target (e.g. https://app.example.com). When empty and the Gateway integration is on, auto-derives from global.gateway.subdomains.ui + domain. When empty and gateway is off, hub-core redirects to "/" relative to the callback host. | +| api.fullnameOverride | string | `""` | | +| api.hmacKey | object | (see fields below) | HMAC key used for session cookie signing (HMAC-SHA256). The Secret must hold a base64-encoded key of at least 32 raw bytes. This is NOT an ECDSA/JWK signing key - see signingKey below for that. | +| api.hmacKey.annotations | object | `{}` | Annotations to add to the generated Secret. Use this to configure CD-specific behavior (e.g. ArgoCD sync options, Flux drift detection). | +| api.hmacKey.existingPreviousSecretRef | object | `{"key":"HMAC_KEY_PREVIOUS","name":""}` | Optional reference for HMAC key rotation. May point at the same Secret as existingSecretRef with a different key, or a different Secret entirely. | +| api.hmacKey.existingSecretRef | object | `{"key":"HMAC_KEY","name":""}` | Reference to a pre-existing Secret holding the active HMAC key. When existingSecretRef.name is set, the chart will not generate one. | +| api.hmacKey.generate | bool | `true` | Generate a random 32-byte HMAC key on first install. Ignored when existingSecretRef.name is set. | +| api.hubInit | object | `{"image":{"digest":"","registry":"","repository":"hub-init","tag":""}}` | Image used by the init-signing-key and init-hmac-key init containers. Runs the hub-init binary; defaults to the chart registry and appVersion like the main api image. | +| api.hubInit.image.digest | string | `""` | Optional SHA256 digest pin. | +| api.hubInit.image.registry | string | `""` | Overrides the chart-wide registry for this image. | +| api.hubInit.image.tag | string | `""` | Overrides the image tag whose default is the chart appVersion. | +| api.image.digest | string | `""` | Optional SHA256 digest pin. | +| api.image.pullPolicy | string | `"IfNotPresent"` | | +| api.image.registry | string | `""` | Overrides the chart-wide registry for this workload. | +| api.image.repository | string | `"hub-core"` | | +| api.image.tag | string | `""` | Overrides the image tag whose default is the chart appVersion. | +| api.initDb | object | (see fields below) | Image used by the init-db init container to wait for PostgreSQL. | +| api.livenessProbe.failureThreshold | int | `3` | | +| api.livenessProbe.httpGet.path | string | `"/livez"` | | +| api.livenessProbe.httpGet.port | string | `"http"` | | +| api.livenessProbe.initialDelaySeconds | int | `0` | | +| api.livenessProbe.periodSeconds | int | `10` | | +| api.livenessProbe.successThreshold | int | `1` | | +| api.livenessProbe.timeoutSeconds | int | `1` | | +| api.migration | object | `{"extraInitContainers":[],"extraVolumeMounts":[],"extraVolumes":[],"serviceAccount":{"annotations":{},"automount":true,"create":true,"name":""}}` | Dedicated ServiceAccount for the DB migration Job. | +| api.migration.extraInitContainers | list | `[]` | Additional init containers to attach to the migration Job pod. | +| api.migration.extraVolumeMounts | list | `[]` | Additional volume mounts on the sql-migrate container. | +| api.migration.extraVolumes | list | `[]` | Additional volumes attached to the migration Job pod. | +| api.migration.serviceAccount.name | string | `""` | Override the generated SA name. When create is false this MUST be set to an SA that exists in the release namespace before install. | +| api.nameOverride | string | `""` | | +| api.nodeSelector | object | `{}` | | +| api.pdb | object | `{"create":false,"maxUnavailable":1,"minAvailable":""}` | PodDisruptionBudget. Off by default; turn on when replicaCount > 1 or autoscaling is enabled. | +| api.podAnnotations | object | `{}` | This is for setting Kubernetes Annotations to a Pod. For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ | +| api.podLabels | object | `{}` | This is for setting Kubernetes Labels to a Pod. For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ | +| api.podSecurityContext.fsGroup | int | `65532` | | +| api.podSecurityContext.runAsGroup | int | `65532` | | +| api.podSecurityContext.runAsNonRoot | bool | `true` | | +| api.podSecurityContext.runAsUser | int | `65532` | | +| api.podSecurityContext.seccompProfile.type | string | `"RuntimeDefault"` | | +| api.readinessProbe.failureThreshold | int | `3` | | +| api.readinessProbe.httpGet.path | string | `"/readyz"` | | +| api.readinessProbe.httpGet.port | string | `"http"` | | +| api.readinessProbe.initialDelaySeconds | int | `5` | | +| api.readinessProbe.periodSeconds | int | `3` | | +| api.readinessProbe.successThreshold | int | `1` | | +| api.readinessProbe.timeoutSeconds | int | `1` | | +| api.replicaCount | int | `1` | This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ | +| api.resources | object | `{}` | | +| api.sampleEmailBasedOIDCConfig | object | `{"allowedDomain":"","clientID":"","clientSecret":"","groupsClaim":"groups","issuerURL":"","providerName":""}` | Sample OIDC identity-provider bootstrap based on the email claim. When issuerURL and clientID are set, an IdentityProvider YAML file is appended to the bootstrap config Secret. This provider: - drives the browser redirect login, unless global.demo.enabled is set, in which case the demo keycloak provider keeps that role - redirects to the issuer's discovery-advertised authorization URL - uses the specified client ID and secret - authenticates user with username "<providerName>:<email_claim>" - maps the groupsClaim to hub groups, prefixed with "<providerName>:" - requires the email_verified claim to be true - (if allowedDomain is set) forces the email string to end with @<allowedDomain> | +| api.sampleEmailBasedOIDCConfig.allowedDomain | string | `""` | Optional: restrict logins to a single email domain | +| api.sampleEmailBasedOIDCConfig.clientID | string | `""` | OIDC client ID (audience) | +| api.sampleEmailBasedOIDCConfig.clientSecret | string | `""` | OIDC client secret for the redirect flow | +| api.sampleEmailBasedOIDCConfig.groupsClaim | string | `"groups"` | Token claim carrying the user's group membership. Group values are prefixed with "<providerName>:", so an OrganizationRoleBinding subject for the "admin" group is named "<providerName>:admin". Providers that publish groups under a non-standard claim need this overridden -- e.g. Amazon Cognito uses "cognito:groups". Set to "" to omit the mapping, which leaves only per-user (email) role bindings working. | +| api.sampleEmailBasedOIDCConfig.issuerURL | string | `""` | OIDC issuer URL (e.g. https://accounts.google.com) | +| api.sampleEmailBasedOIDCConfig.providerName | string | `""` | Name of the OIDC provider (default: "oidc") | +| api.securityContext.allowPrivilegeEscalation | bool | `false` | | +| api.securityContext.capabilities.drop[0] | string | `"ALL"` | | +| api.securityContext.privileged | bool | `false` | | +| api.securityContext.readOnlyRootFilesystem | bool | `true` | | +| api.securityContext.runAsGroup | int | `65532` | | +| api.securityContext.runAsNonRoot | bool | `true` | | +| api.securityContext.runAsUser | int | `65532` | | +| api.securityContext.seccompProfile.type | string | `"RuntimeDefault"` | | +| api.service | object | `{"annotations":{},"api":{"port":8080,"type":"ClusterIP"},"metrics":{"port":8085,"type":"ClusterIP"}}` | This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ | +| api.service.api | object | `{"port":8080,"type":"ClusterIP"}` | The main service for the API. | +| api.service.metrics | object | `{"port":8085,"type":"ClusterIP"}` | The Prometheus metrics endpoint service for the API. | +| api.serviceAccount | object | `{"annotations":{},"automount":true,"create":true,"name":""}` | This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ | +| api.serviceAccount.annotations | object | `{}` | Annotations to add to the service account | +| api.serviceAccount.automount | bool | `true` | Automatically mount a ServiceAccount's API credentials? | +| api.serviceAccount.create | bool | `true` | Specifies whether a service account should be created | +| api.serviceAccount.name | string | `""` | The name of the service account to use. If not set and create is true, a name is generated using the fullname template | +| api.signingKey | object | `{"annotations":{},"existingSecretRef":{"name":""},"generate":true}` | Signing key for hub-issued JWTs. Must be an ECDSA P-256 key pair in Kubernetes TLS Secret format (standard tls.crt + tls.key keys). | +| api.signingKey.existingSecretRef | object | `{"name":""}` | Reference to a pre-existing TLS Secret. When existingSecretRef.name is set, the chart will not generate one. The Secret MUST use the standard tls.crt / tls.key keys. | +| api.startupProbe | object | (see fields below) | Startup probe gives slow first-start work (signing-key gen, HMAC gen) room to finish before liveness starts evicting the pod. | +| api.tokenExchange | object | `{"port":8444,"service":{"annotations":{},"type":"ClusterIP"}}` | The internal Hub OIDC token exchange service for the API. | +| api.tolerations | list | `[]` | | +| api.tracing | object | `{"enabled":false,"url":""}` | OpenTelemetry tracing configuration. | +| api.updateStrategy | object | `{"rollingUpdate":{"maxSurge":1,"maxUnavailable":0},"type":"RollingUpdate"}` | Deployment update strategy. Defaults to RollingUpdate with maxSurge=1 / maxUnavailable=0. During a rolling upgrade the pod count stays at replicaCount + 1, never below replicaCount, so an in-flight request is always served by a Ready pod. | +| apisql | object | `{"image":{"repository":"hub-sql-migrate","tag":""}}` | Configuration for the hub-sql-migrate migration container | +| apisql.image.repository | string | `"hub-sql-migrate"` | Override the registry for this image (defaults to global registry) registry: "" | +| apisql.image.tag | string | `""` | Overrides the image tag whose default is the chart appVersion. | +| bootstrap | object | `{"admins":[],"files":{}}` | Bootstrap configuration loaded by hub-core at startup. | +| bootstrap.admins | list | `[]` | Subjects granted both org-admin and realm-admin of the "default" realm. A convenience shortcut so operators declare admins in one place instead of maintaining an OrganizationRoleBinding and a RealmRoleBinding in lockstep. Users who need finer-grained scoping (org-admin only, or realm-admin of a non-default realm) should provide raw manifests via bootstrap.files instead. | +| bootstrap.files | object | `{}` | YAML files to include in the bootstrap config Secret. Each key is a filename and each value is the file content. Use this to register IdentityProviders, ControlPlanes, or OrganizationRoleBindings at install time. To register a connector against a bootstrapped ControlPlane, mint a token via the registrationtoken subresource after install. Example: files: my-cp.yaml: | apiVersion: hub.upbound.io/v1alpha1 kind: ControlPlane metadata: name: my-cp namespace: default | +| global | object | `{}` | Populated by the umbrella chart at install time. | +| httpRoute | object | `{"annotations":{},"domain":"","enabled":false,"hostnames":[],"labels":{},"parentRefs":[],"subdomain":"api"}` | Kubernetes Gateway API HTTPRoute. When installed under the umbrella with global.gateway.enabled, the umbrella's parentRef and domain win. For standalone installs, set enabled=true and supply parentRefs (or subdomain+domain). | +| httpRoute.hostnames | list | `[]` | If set, replaces the composed <subdomain>.<domain> hostname. | +| imagePullSecrets | list | `[]` | Chart-wide image pull secrets. Falls back to global.imagePullSecrets. | +| otelGateway | object | (see fields below) | Feature gate: Metrics (alpha). These values configure the Metrics feature and are only consumed when the Metrics gate is enabled. Enabling that gate requires otelGateway.enabled=true. When enabled, deploys an OpenTelemetry Collector as a gateway that receives metrics from control plane collectors via OTLP and writes them to Prometheus. | +| otelGateway.image | object | `{"digest":"","pullPolicy":"IfNotPresent","registry":"","repository":"opentelemetry-collector-hub","tag":""}` | OpenTelemetry Collector image for the otel-gateway. Defaults to Upbound's build, which includes only the components this pipeline needs; override the fields below to run any other collector image. | +| otelGateway.image.digest | string | `""` | Optional SHA256 digest pin. | +| otelGateway.image.registry | string | `""` | Overrides the chart-wide registry for this workload. | +| otelGateway.image.tag | string | `""` | Overrides the image tag whose default is the chart appVersion. | +| otelGateway.metricAllowlist | list | `["controller_runtime_reconcile_total","controller_runtime_reconcile_errors_total","controller_runtime_reconcile_time_seconds.*","upjet_resource_external_api_calls_total","function_run_function_seconds.*","kube_customresource_.*"]` | Safety-net metric allowlist - re-validates the curated set in case a source collector sends unfiltered data. | +| otelGateway.metrics | object | `{"amp":{"region":"","writeEndpoint":""},"backend":"","gmp":{"project":""},"prometheus":{"insecure":true,"writeEndpoint":""},"queryURL":""}` | metrics selects which Prometheus-compatible backend the gateway ships to, and where the metrics query API reads them back. Only the selected backend's settings are used. | +| otelGateway.metrics.backend | string | `""` | backend selects the exporter. Required when otelGateway.enabled - set it explicitly rather than relying on a default. prometheus - Prometheus remote-write (self-hosted, or any remote-write endpoint) gmp - native Google Managed Prometheus exporter (gRPC, ADC) amp - Amazon Managed Prometheus: remote-write signed with AWS SigV4 (credentials from the pod, e.g. IRSA) | +| otelGateway.metrics.gmp | object | `{"project":""}` | Settings for the "gmp" backend. The exporter writes to the Google Cloud Monitoring API - there is no endpoint to configure, only the project - and authenticates via Application Default Credentials, so the gateway ServiceAccount must carry a Google identity with roles/monitoring.metricWriter (Workload Identity or a mounted key), wired at deploy time. | +| otelGateway.metrics.gmp.project | string | `""` | GCP project metrics are written to. Auto-detected from the pod environment when empty. | +| otelGateway.metrics.prometheus | object | `{"insecure":true,"writeEndpoint":""}` | Settings for the "prometheus" backend. | +| otelGateway.metrics.prometheus.insecure | bool | `true` | Disable client transport security. Does not skip certificate verification: an https:// writeEndpoint stays verified either way. | +| otelGateway.metrics.prometheus.writeEndpoint | string | `""` | Remote-write URL the gateway pushes metrics to. | +| otelGateway.metrics.queryURL | string | `""` | Base URL the metrics query API reads from. Deployment-specific: a Prometheus/Thanos service, a GMP-compatible endpoint (e.g. a GMP frontend proxy) for the gmp backend, or the AMP workspace URL for the amp backend. | +| otelGateway.service.grpc | object | `{"port":4317}` | The OTLP gRPC receiver port. | +| otelGateway.service.http | object | `{"port":4318}` | The OTLP HTTP receiver port. | +| postgresql.auth.aws | object | `{"region":""}` | AWS RDS IAM settings, used when cloud is "aws". | +| postgresql.auth.aws.region | string | `""` | RDS region. Falls back to the AWS_REGION env var when unset. | +| postgresql.auth.cloud | string | `""` | Cloud provider for IAM auth. Required when mode is "iam". One of: "aws", "gcp", "azure" (only "aws" is implemented today). | +| postgresql.auth.mode | string | `"password"` | Authentication mode: "password" (default) or "iam". In iam mode hub-core mints a short-lived cloud IAM token per connection - no static credential is set, and the connection is forced over TLS (sslmode=require minimum). | +| postgresql.auth.password | object | `{"existingSecretRef":{"key":"password","name":""},"value":""}` | Postgres password. Used in password auth mode. Provide either the plaintext value (dev only) or a reference to an existing Secret. | +| postgresql.auth.password.existingSecretRef | object | `{"key":"password","name":""}` | Reference to an existing Secret holding the password. | +| postgresql.auth.password.value | string | `""` | Plaintext password — dev only, not recommended for production. | +| postgresql.connectionString | string | `""` | Option 1: Provide a full connection string (takes precedence over individual parameters) | +| postgresql.database | string | `"postgres"` | | +| postgresql.host | string | `""` | Option 2: Provide individual connection parameters | +| postgresql.port | int | `5432` | | +| postgresql.sslmode | string | `"disable"` | | +| postgresql.user | string | `"postgres"` | | +| registry | string | `"xpkg.upbound.io/upbound"` | Chart-wide default registry. Per-workload <workload>.image.registry overrides this. Falls back to global.imageRegistry when both are empty. | + +## `hub-connector` chart + +A Helm chart for deploying the Hub Connector + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| collector | object | (see fields below) | OTEL collector that scrapes control plane metrics, filters to a curated set, stamps identity labels, and exports to the hub-side gateway. | +| collector.image | object | `{"digest":"","pullPolicy":"IfNotPresent","registry":"","repository":"opentelemetry-collector-hub","tag":""}` | OpenTelemetry Collector image for the connector's scrape collector. Defaults to Upbound's build, which includes only the components this pipeline needs; override the fields below to run any other collector image. | +| collector.image.digest | string | `""` | Optional SHA256 digest pin. | +| collector.image.registry | string | `""` | Overrides the chart-wide registry for this workload. | +| collector.image.tag | string | `""` | Overrides the image tag whose default is the chart appVersion. | +| collector.metricAllowlist | list | `["controller_runtime_reconcile_total","controller_runtime_reconcile_errors_total","controller_runtime_reconcile_time_seconds.*","upjet_resource_external_api_calls_total","function_run_function_seconds.*","kube_customresource_.*"]` | Curated metric allowlist - only these metrics leave the control plane. | +| collector.scrapeNamespaces | list | `["crossplane-system","upbound-system"]` | Namespaces to scrape for Crossplane metrics. The collector discovers pods with label app=crossplane in these namespaces and scrapes port 8080. - Vanilla Crossplane: ["crossplane-system"] - UXP: ["upbound-system"] | +| connector | object | (see fields below) | Main connector workload. Exchanges registration token for a hub credential, pushes JWKS, and syncs cluster resources to the hub. | +| connector.credentials | object | `{"existingSecretRef":{"key":"registrationToken","name":"hub-connector-credentials"}}` | Credentials the connector exchanges for a Hub JWT on first start. | +| connector.featureFlags | object | `{"enabled":false,"ofrepHost":"","ofrepPort":8016}` | Feature flag evaluation via hub-core's OFREP endpoint. | +| connector.hub | object | `{"allowInsecure":false,"tokenExchangeUrl":"","url":""}` | hub-core connection. Where the connector talks to. | +| connector.hub.allowInsecure | bool | `false` | Permit plaintext HTTP to hub.url. Required when running against a non-TLS Hub (e.g. demo mode or an in-cluster Service). | +| connector.hub.tokenExchangeUrl | string | `""` | URL of the Hub token exchange endpoint. Defaults to hub.url. | +| connector.hub.url | string | `""` | hub-core URL. If empty, falls back to global.apiServiceName + global.apiServicePort. | +| connector.hubAuthServiceAccount | object | `{"name":"","namespace":""}` | ServiceAccount whose token the connector mints from the control plane it represents and exchanges for a hub credential. Defaults to the connector's own ServiceAccount in the release namespace, which is correct for an in-cluster install. For a connector that reaches its control plane through a mounted kubeconfig, set these to a ServiceAccount that exists in that cluster. | +| connector.image.registry | string | `""` | Overrides the chart-wide registry for this workload. | +| connector.initImage | string | `"busybox:1.38"` | Image used by the demo-mode init container that waits for hub-core. | +| connector.jwks | object | `{"pushInterval":"1h"}` | JWKS publishing. The connector pushes its cluster's signing keys to hub-core so the hub can verify the tokens the connector mints. | +| connector.livenessProbe | object | `{}` | TODO: connector binary doesn't yet serve /livez/readyz. Once a health listener is added, populate these with the standard probe quartet. | +| connector.service | object | `{"annotations":{},"port":4318,"type":"ClusterIP"}` | The connector's local metrics-proxy Service. Only rendered when collector.enabled is true; the collector forwards through it to the upstream hub-side OTEL gateway. | +| connector.spaces | object | (see fields below) | Spaces integration. When enabled, reconciles ObjectRoleBinding and RealmRoleBinding resources against a Spaces API server. | +| connector.spaces.controlPlaneConnector | object | `{"debug":null,"excludeResources":[],"jwksPushInterval":null,"rediscoveryInterval":null,"resources":{}}` | Config for the per-control-plane connectors a space connector deploys. Each field defaults to the space connector's own config; set one to override it for every control plane connector in the space. | +| connector.spaces.controlPlaneConnector.excludeResources | list | `[]` | Resources to exclude from syncing, formatted as "apiVersion=<apiVersion>,kind=<Kind>". Can be repeated. | +| connector.spaces.controlPlaneConnector.resources | object | `{}` | Compute resource requests/limits for control plane connectors. Defaults to the space connector's own resources; set to override. | +| connector.sync | object | `{"excludeResources":[],"limitToClusterRoles":["crossplane-admin"],"rediscoveryInterval":"20s"}` | Resource sync - controls which Kubernetes resources are synced to the Hub and how aggressively the connector rediscovers the cluster API surface. | +| connector.sync.excludeResources | list | `[]` | Resources to exclude from syncing, formatted as "apiVersion=<apiVersion>,kind=<Kind>". Can be repeated. | +| connector.sync.limitToClusterRoles | list | `["crossplane-admin"]` | ClusterRoles whose rules define which resources are synced. Only resources authorized by at least one of these roles are synced. Set to [] to sync all resources (no ClusterRole filtering). In demo mode (global.demo.enabled) this default is replaced with the built-in cluster-admin role to smooth the demo experience. | +| global | object | `{}` | Populated by the umbrella chart at install time. | +| imagePullSecrets | list | `[]` | Chart-wide image pull secrets. Falls back to global.imagePullSecrets. | +| registry | string | `"xpkg.upbound.io/upbound"` | Chart-wide default registry. Per-workload <workload>.image.registry overrides this. Falls back to global.imageRegistry when both are empty. | +| rsm | object | (see fields below) | Resource State Metrics. Exposes per-resource condition gauges. Wired through the collector. Only rendered when collector.enabled AND rsm.enabled. | +| rsm.apiGroups | list | `["*"]` | API groups to watch. Controls both RBAC and the ResourceMetricsMonitor stores - one store per group. Each group creates informers that consume memory. Defaults to all groups. To reduce resource usage, scope to specific provider groups (e.g. ["pkg.crossplane.io", "s3.aws.upbound.io"]). | +| rsm.image.registry | string | `"xpkg.crossplane.io"` | Overrides the chart-wide registry for this workload. | +| rsm.monitor.create | bool | `true` | Whether to render the ResourceMetricsMonitor in the release namespace. | diff --git a/hub-docs/reference/index.md b/hub-docs/reference/index.md new file mode 100644 index 000000000..229e66b63 --- /dev/null +++ b/hub-docs/reference/index.md @@ -0,0 +1,15 @@ +--- +title: Reference +sidebar_position: 1 +description: Reference material for Hub, including feature flags and the feature lifecycle. +--- + +Reference material for operating the hub. + +- [Feature flags][feature-flags]. Enable optional features through Helm + values. +- [Feature lifecycle][feature-releases]. The alpha, beta, and GA feature + stages and the Hub release cycle. + +[feature-flags]: /hub/reference/feature-flags +[feature-releases]: /hub/reference/feature-releases diff --git a/hub_versions.json b/hub_versions.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/hub_versions.json @@ -0,0 +1 @@ +[] diff --git a/package-lock.json b/package-lock.json index 6f1c1db61..cf374757d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "@docusaurus/plugin-content-docs": "^3.10.2", "@docusaurus/plugin-google-gtag": "^3.10.2", "@docusaurus/preset-classic": "^3.10.2", - "@docusaurus/theme-live-codeblock": "^3.10.2", "@docusaurus/theme-mermaid": "^3.10.2", "@docusaurus/theme-search-algolia": "^3.10.2", "@mdx-js/react": "^3.0.0", @@ -27,7 +26,6 @@ "@upbound/utils": "0.0.1", "@upbound/ux": "0.0.2", "clsx": "^2.1.1", - "docusaurus-pushfeedback": "^1.0.5", "gray-matter": "^4.0.3", "js-yaml": "^4.3.0", "launchdarkly-js-client-sdk": "3.5.0", @@ -40,11 +38,8 @@ "devDependencies": { "@docusaurus/module-type-aliases": "3.9.1", "@docusaurus/types": "3.9.1", - "autoprefixer": "^10.4.22", "esbuild-loader": "^4.5.0", - "postcss": "^8.5.10", "process": "^0.11.10", - "tailwindcss": "^4.1.17", "yaml-loader": "^0.8.1" }, "engines": { @@ -4417,30 +4412,6 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, - "node_modules/@docusaurus/theme-live-codeblock": { - "version": "3.10.2", - "resolved": "https://registry.npmjs.org/@docusaurus/theme-live-codeblock/-/theme-live-codeblock-3.10.2.tgz", - "integrity": "sha512-rBWmjCFaInkWENi4EjHX4lgwN6uBfVurN8SdK0evZ5LwjIiAdVRTRkmnWNPswiud3UfdSeNalaLQl62Ks9d+pA==", - "license": "MIT", - "dependencies": { - "@docusaurus/core": "3.10.2", - "@docusaurus/theme-common": "3.10.2", - "@docusaurus/theme-translations": "3.10.2", - "@docusaurus/utils-validation": "3.10.2", - "@philpl/buble": "^0.19.7", - "clsx": "^2.0.0", - "fs-extra": "^11.1.1", - "react-live": "^4.1.6", - "tslib": "^2.6.0" - }, - "engines": { - "node": ">=20.0" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - } - }, "node_modules/@docusaurus/theme-mermaid": { "version": "3.10.2", "resolved": "https://registry.npmjs.org/@docusaurus/theme-mermaid/-/theme-mermaid-3.10.2.tgz", @@ -6128,136 +6099,6 @@ "node": ">=20.0.0" } }, - "node_modules/@philpl/buble": { - "version": "0.19.7", - "license": "MIT", - "dependencies": { - "acorn": "^6.1.1", - "acorn-class-fields": "^0.2.1", - "acorn-dynamic-import": "^4.0.0", - "acorn-jsx": "^5.0.1", - "chalk": "^2.4.2", - "magic-string": "^0.25.2", - "minimist": "^1.2.0", - "os-homedir": "^1.0.1", - "regexpu-core": "^4.5.4" - }, - "bin": { - "buble": "bin/buble" - } - }, - "node_modules/@philpl/buble/node_modules/acorn": { - "version": "6.4.2", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/@philpl/buble/node_modules/ansi-styles": { - "version": "3.2.1", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@philpl/buble/node_modules/chalk": { - "version": "2.4.2", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@philpl/buble/node_modules/color-convert": { - "version": "1.9.3", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@philpl/buble/node_modules/color-name": { - "version": "1.1.3", - "license": "MIT" - }, - "node_modules/@philpl/buble/node_modules/escape-string-regexp": { - "version": "1.0.5", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@philpl/buble/node_modules/has-flag": { - "version": "3.0.0", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/@philpl/buble/node_modules/jsesc": { - "version": "0.5.0", - "bin": { - "jsesc": "bin/jsesc" - } - }, - "node_modules/@philpl/buble/node_modules/regenerate-unicode-properties": { - "version": "9.0.0", - "license": "MIT", - "dependencies": { - "regenerate": "^1.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@philpl/buble/node_modules/regexpu-core": { - "version": "4.8.0", - "license": "MIT", - "dependencies": { - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^9.0.0", - "regjsgen": "^0.5.2", - "regjsparser": "^0.7.0", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@philpl/buble/node_modules/regjsgen": { - "version": "0.5.2", - "license": "MIT" - }, - "node_modules/@philpl/buble/node_modules/regjsparser": { - "version": "0.7.0", - "license": "BSD-2-Clause", - "dependencies": { - "jsesc": "~0.5.0" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, - "node_modules/@philpl/buble/node_modules/supports-color": { - "version": "5.5.0", - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "license": "MIT", @@ -8721,23 +8562,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-class-fields": { - "version": "0.2.1", - "license": "MIT", - "engines": { - "node": ">=4.8.2" - }, - "peerDependencies": { - "acorn": "^6.0.0" - } - }, - "node_modules/acorn-dynamic-import": { - "version": "4.0.0", - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0" - } - }, "node_modules/acorn-import-phases": { "version": "1.0.4", "license": "MIT", @@ -8937,10 +8761,6 @@ "node": ">=14" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "license": "MIT" - }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -11303,13 +11123,6 @@ "node": ">=6" } }, - "node_modules/docusaurus-pushfeedback": { - "version": "1.0.9", - "license": "MIT", - "peerDependencies": { - "@docusaurus/core": "3.x" - } - }, "node_modules/dom-converter": { "version": "0.2.0", "license": "MIT", @@ -12084,21 +11897,6 @@ "node": ">=0.8.0" } }, - "node_modules/fdir": { - "version": "6.5.0", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/feed": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/feed/-/feed-4.2.2.tgz", @@ -13994,13 +13792,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/magic-string": { - "version": "0.25.9", - "license": "MIT", - "dependencies": { - "sourcemap-codec": "^1.4.8" - } - }, "node_modules/markdown-extensions": { "version": "2.0.0", "license": "MIT", @@ -16335,15 +16126,6 @@ "multicast-dns": "cli.js" } }, - "node_modules/mz": { - "version": "2.7.0", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, "node_modules/nanoid": { "version": "3.3.11", "funding": [ @@ -16636,13 +16418,6 @@ "opener": "bin/opener-bin.js" } }, - "node_modules/os-homedir": { - "version": "1.0.2", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/p-cancelable": { "version": "3.0.0", "license": "MIT", @@ -16948,13 +16723,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pirates": { - "version": "4.0.7", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/pkg-dir": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", @@ -18766,23 +18534,6 @@ "react": "^18.0.0 || ^19.0.0" } }, - "node_modules/react-live": { - "version": "4.1.8", - "license": "MIT", - "dependencies": { - "prism-react-renderer": "^2.4.0", - "sucrase": "^3.35.0", - "use-editable": "^2.3.3" - }, - "engines": { - "node": ">= 0.12.0", - "npm": ">= 2.0.0" - }, - "peerDependencies": { - "react": ">=18.0.0", - "react-dom": ">=18.0.0" - } - }, "node_modules/react-loadable": { "name": "@docusaurus/react-loadable", "version": "6.0.0", @@ -20149,10 +19900,6 @@ "node": ">=0.10.0" } }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "license": "MIT" - }, "node_modules/space-separated-tokens": { "version": "2.0.2", "license": "MIT", @@ -20371,33 +20118,6 @@ "version": "4.2.0", "license": "MIT" }, - "node_modules/sucrase": { - "version": "3.35.1", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/sucrase/node_modules/commander": { - "version": "4.1.1", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/supports-color": { "version": "7.2.0", "license": "MIT", @@ -20458,11 +20178,6 @@ "node": ">= 10" } }, - "node_modules/tailwindcss": { - "version": "4.1.18", - "dev": true, - "license": "MIT" - }, "node_modules/tapable": { "version": "2.3.0", "license": "MIT", @@ -20579,23 +20294,6 @@ "version": "2.20.3", "license": "MIT" }, - "node_modules/thenify": { - "version": "3.3.1", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/thingies": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", @@ -20635,32 +20333,6 @@ "node": ">=18" } }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -20743,10 +20415,6 @@ "node": ">=6.10" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "license": "Apache-2.0" - }, "node_modules/tslib": { "version": "2.8.1", "license": "0BSD" @@ -21205,13 +20873,6 @@ } } }, - "node_modules/use-editable": { - "version": "2.3.3", - "license": "MIT", - "peerDependencies": { - "react": ">= 16.8.0" - } - }, "node_modules/use-sidecar": { "version": "1.1.3", "license": "MIT", diff --git a/package.json b/package.json index afab984d3..52c460019 100644 --- a/package.json +++ b/package.json @@ -4,14 +4,13 @@ "private": true, "scripts": { "docusaurus": "docusaurus", - "start": "npm run process-crds && docusaurus start --host 0.0.0.0", - "build": "npm run process-crds && docusaurus build", + "start": "docusaurus start --host 0.0.0.0", + "build": "docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", - "process-crds": "node scripts/process-crds.js", "write-heading-ids": "docusaurus write-heading-ids" }, "dependencies": { @@ -20,7 +19,6 @@ "@docusaurus/plugin-content-docs": "^3.10.2", "@docusaurus/plugin-google-gtag": "^3.10.2", "@docusaurus/preset-classic": "^3.10.2", - "@docusaurus/theme-live-codeblock": "^3.10.2", "@docusaurus/theme-mermaid": "^3.10.2", "@docusaurus/theme-search-algolia": "^3.10.2", "@mdx-js/react": "^3.0.0", @@ -34,7 +32,6 @@ "@upbound/utils": "0.0.1", "@upbound/ux": "0.0.2", "clsx": "^2.1.1", - "docusaurus-pushfeedback": "^1.0.5", "gray-matter": "^4.0.3", "js-yaml": "^4.3.0", "launchdarkly-js-client-sdk": "3.5.0", @@ -47,11 +44,8 @@ "devDependencies": { "@docusaurus/module-type-aliases": "3.9.1", "@docusaurus/types": "3.9.1", - "autoprefixer": "^10.4.22", "esbuild-loader": "^4.5.0", - "postcss": "^8.5.10", "process": "^0.11.10", - "tailwindcss": "^4.1.17", "yaml-loader": "^0.8.1" }, "browserslist": { diff --git a/quickstart.sh b/quickstart.sh new file mode 100644 index 000000000..c7962459b --- /dev/null +++ b/quickstart.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +for bin in kind helm kubectl up; do + command -v "$bin" >/dev/null 2>&1 || { echo "error: '$bin' not found on PATH" >&2; exit 1; } +done + +echo "==> Creating KIND cluster 'quickstart'" +kind create cluster --name quickstart + +echo "==> Logging in to Upbound" +up login + +echo "==> Installing the Insights platform (demo)" +helm upgrade --install hub oci://xpkg.upbound.io/upbound/hub \ + -n hub --create-namespace --set global.demo.enabled=true --version 1.0.0-rc.1 + +echo "==> Waiting for Insights components" +kubectl -n hub wait --for=condition=ready pod --all --timeout=5m + +echo "==> Forwarding the Console gateway to https://hub.127.0.0.1.nip.io:8443" +gateway_svc=$(kubectl -n hub get svc -l gateway.envoyproxy.io/owning-gateway-name=hub-gateway -o jsonpath='{.items[0].metadata.name}') +kubectl -n hub port-forward "svc/${gateway_svc}" 8443:8443 & +pf_pid=$! +trap 'kill "$pf_pid" 2>/dev/null || true' EXIT + +echo "==> Installing UXP (Crossplane)" +up uxp install +kubectl -n crossplane-system wait --for=condition=ready pod --all --timeout=5m + +echo "==> Initializing project 'my-webapp'" +up project init -t project-template-k8s-webapp -l python my-webapp + +echo "==> Running the project on the current cluster" +cd my-webapp +up project run --use-current-context + +cat <<'EOF' + +Hub: https://hub.127.0.0.1.nip.io:8443 (login admin / admin) +Hub port-forward running. When you're done with this quickstart, press Ctrl+C to stop it. +EOF + +wait "$pf_pid" diff --git a/scripts/manifest-embed-plugin.js b/scripts/manifest-embed-plugin.js new file mode 100644 index 000000000..02752b01f --- /dev/null +++ b/scripts/manifest-embed-plugin.js @@ -0,0 +1,49 @@ +// Embeds the contents of a file under static/ into a fenced code block at +// build time, so long manifests/scripts live in their own file instead of +// bloating the markdown source. Usage: +// +// ```yaml title="./example.yaml" manifest="/manifests/getstarted/example.yaml" +// ``` +// +// The fence body is ignored (and should be left empty) -- it's replaced with +// the referenced file's contents. +const fs = require("fs"); +const path = require("path"); + +const EXT_TO_LANG = { + yaml: "yaml", + yml: "yaml", + py: "python", + k: "kcl", + go: "go", + sh: "shell", + json: "json", +}; + +module.exports = function manifestEmbedPlugin() { + return function transformer(tree) { + visit(tree); + }; + + function visit(node) { + if (node.type === "code" && node.meta) { + const match = node.meta.match(/manifest="([^"]+)"/); + if (match) { + const manifestPath = path.join(process.cwd(), "static", match[1]); + if (!fs.existsSync(manifestPath)) { + throw new Error( + `manifest-embed-plugin: file not found: ${manifestPath} (referenced as manifest="${match[1]}")` + ); + } + node.value = fs.readFileSync(manifestPath, "utf8").replace(/\n$/, ""); + if (!node.lang) { + const ext = path.extname(manifestPath).slice(1); + node.lang = EXT_TO_LANG[ext] || ext; + } + } + } + if (node.children) { + node.children.forEach(visit); + } + } +}; diff --git a/scripts/plan-plugin.js b/scripts/plan-plugin.js index edcae7adf..d625aa126 100644 --- a/scripts/plan-plugin.js +++ b/scripts/plan-plugin.js @@ -10,36 +10,28 @@ module.exports = function planPlugin(context, options) { async loadContent() { const planData = {}; const docsPath = path.join(context.siteDir, 'docs'); - - function processDirectory(dir, relativePath = '') { - const items = fs.readdirSync(dir); - - items.forEach(item => { - const itemPath = path.join(dir, item); - const stats = fs.statSync(itemPath); - - if (stats.isDirectory()) { - processDirectory(itemPath, path.join(relativePath, item)); - } else if (item.endsWith('.md') || item.endsWith('.mdx')) { - const content = fs.readFileSync(itemPath, 'utf8'); - const { data: frontmatter } = matter(content); - - if (frontmatter.plan) { - const docId = path.join(relativePath, item.replace(/\.(md|mdx)$/, '')); - planData[docId] = { - plan: frontmatter.plan, - title: frontmatter.title, - path: itemPath - }; - } + + if (fs.existsSync(docsPath)) { + const docFiles = fs + .readdirSync(docsPath, { recursive: true }) + .filter((item) => item.endsWith('.md') || item.endsWith('.mdx')); + + docFiles.forEach((relativePath) => { + const itemPath = path.join(docsPath, relativePath); + const content = fs.readFileSync(itemPath, 'utf8'); + const { data: frontmatter } = matter(content); + + if (frontmatter.plan) { + const docId = relativePath.replace(/\.(md|mdx)$/, ''); + planData[docId] = { + plan: frontmatter.plan, + title: frontmatter.title, + path: itemPath + }; } }); } - - if (fs.existsSync(docsPath)) { - processDirectory(docsPath); - } - + return { planData }; }, diff --git a/scripts/process-crds.js b/scripts/process-crds.js deleted file mode 100644 index 645e80556..000000000 --- a/scripts/process-crds.js +++ /dev/null @@ -1,92 +0,0 @@ -// scripts/process-crds.js -const fs = require('fs'); -const path = require('path'); -const yaml = require('js-yaml'); - -// Define the CRD directories and their output locations -const crdDirectories = [ - { - source: 'docs/apis-cli/query-api/yaml', - output: 'docs/apis-cli/query-api/yaml', - name: 'Query API' - }, - { - source: 'docs/apis-cli/spaces-api/yaml', - output: 'docs/apis-cli/spaces-api/yaml', - name: 'Spaces API' - }, - { - source: 'docs/apis-cli/testing-api/yaml', - output: 'docs/apis-cli/testing/yaml', - name: 'Testing API' - } -]; - -crdDirectories.forEach(dir => { - processDirectory(dir.source, dir.output, dir.name); -}); - -function processDirectory(sourceDir, outputDir, apiName) { - console.log(`Processing ${apiName} CRDs from: ${sourceDir}`); - - if (!fs.existsSync(sourceDir)) { - console.warn(`Source directory not found: ${sourceDir} - skipping`); - return; - } - - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - console.log(`Created output directory: ${outputDir}`); - } - - try { - const files = fs.readdirSync(sourceDir) - .filter(file => file.endsWith('.yaml') || file.endsWith('.yml')); - - console.log(`Found ${files.length} YAML files in ${sourceDir}`); - - if (files.length === 0) { - console.warn(`No YAML files found in ${sourceDir}`); - return; - } - - files.forEach(file => { - try { - const crdPath = path.join(sourceDir, file); - const crdContent = fs.readFileSync(crdPath, 'utf8'); - - try { - const crd = yaml.load(crdContent); - - if (!crd.kind || crd.kind !== 'CustomResourceDefinition') { - console.warn(`Skipping ${file}: Not a CRD`); - return; - } - - const kind = crd.spec.names.kind; - const group = crd.spec.group; - console.log(`Processing CRD: ${kind} (${group})`); - - } catch (yamlError) { - console.error(`Error parsing YAML in ${file}:`, yamlError.message); - return; - } - - const outputPath = path.join(outputDir, file); - - - fs.copyFileSync(crdPath, outputPath); - - console.log(`Copied ${crdPath} -> ${outputPath}`); - - } catch (error) { - console.error(`Error processing ${file}:`, error.message); - } - }); - - } catch (error) { - console.error(`Error reading directory ${sourceDir}:`, error.message); - } -} - -console.log('CRD processing complete!'); diff --git a/self-hosted-spaces-docs/howtos/capacity-licensing.md b/self-hosted-spaces-docs/howtos/capacity-licensing.md index d5230493f..5c401f109 100644 --- a/self-hosted-spaces-docs/howtos/capacity-licensing.md +++ b/self-hosted-spaces-docs/howtos/capacity-licensing.md @@ -586,7 +586,7 @@ If your license shows as invalid: [CloudNativePG]: https://cloudnative-pg.io/ [backups]: https://cloudnative-pg.io/documentation/current/backup_recovery/ [backup-restore]: /self-hosted-spaces/howtos/backup-and-restore -[sales]: https://www.upbound.io/contact +[sales]: https://www.upbound.io/contact-us [eso]: https://external-secrets.io/ [Observability]: /self-hosted-spaces/howtos/observability diff --git a/self-hosted-spaces-docs/howtos/spaces-management.md b/self-hosted-spaces-docs/howtos/spaces-management.md index 67fee932e..057ffbe9e 100644 --- a/self-hosted-spaces-docs/howtos/spaces-management.md +++ b/self-hosted-spaces-docs/howtos/spaces-management.md @@ -199,7 +199,7 @@ kubectl delete controlplane ctp1 [up-space-init]: /reference/cli-reference -[quickstart]: / +[quickstart]: /self-hosted-spaces/self-hosted-spaces-quickstart [aws]: /self-hosted-spaces/howtos/self-hosted-spaces-deployment [azure]:/self-hosted-spaces/howtos/self-hosted-spaces-deployment [gcp]:/self-hosted-spaces/howtos/self-hosted-spaces-deployment diff --git a/self-hosted-spaces-docs/self-hosted-spaces-quickstart.md b/self-hosted-spaces-docs/self-hosted-spaces-quickstart.md index 798074afb..93443d4b1 100644 --- a/self-hosted-spaces-docs/self-hosted-spaces-quickstart.md +++ b/self-hosted-spaces-docs/self-hosted-spaces-quickstart.md @@ -8,7 +8,7 @@ tier: "business" Get started with Upbound Spaces using a local `kind` cluster. This guide walks you through deploying a self-hosted Space, creating your first control plane, and optionally connecting your Space to the Upbound Console. :::info -Self-hosted Spaces are a business critical feature of Upbound and require a license token. [Contact Upbound](https://www.upbound.io/contact) if you want to try out self-hosted Spaces. +Self-hosted Spaces are a business critical feature of Upbound and require a license token. [Contact Upbound](https://www.upbound.io/contact-us) if you want to try out self-hosted Spaces. ::: ## Prerequisites diff --git a/self-hosted-spaces_versioned_docs/upgrade-to-mcps.md b/self-hosted-spaces_versioned_docs/upgrade-to-mcps.md new file mode 100644 index 000000000..15e200be1 --- /dev/null +++ b/self-hosted-spaces_versioned_docs/upgrade-to-mcps.md @@ -0,0 +1,431 @@ +--- +title: Upgrade to Spaces +sidebar_position: 4 +description: A guide to how to update to a control plane in an Upbound Space +--- + +The Upbound migration [command][cli-command] that helps you update +your existing Crossplane control plane to a managed [Upbound Crossplane][uxp] +control plane in an [Upbound Space][spaces]. + +To upgrade from Crossplane to Upbound, you must: + +1. Export your existing Crossplane control plane configuration/state into an archive file. +2. Import the archive file into a control plane running in Upbound. +The migration tool is available in the [up CLI][up-cli] as +`up controlplane migration export` and `up controlplane migration import` commands. + +## Prerequisites + +Before you begin, you must have the following: +- The [up CLI][up-cli-1] version 0.23.0 or later. + +## Migration process + +To upgrade an existing Crossplane control plane to a control plane in Upbound, do the following: + +1. Run the `up controlplane migration export` command to export your existing Crossplane control plane configuration/state into an archive file: + + ```bash + up controlplane migration export --kubeconfig --output + ``` + + The command exports your existing Crossplane control plane configuration/state into an archive file. + +:::note +By default, the export command doesn't make any changes to your existing +Crossplane control plane state, leaving it intact. Use the +`--pause-before-export` flag to pause the reconciliation on managed resources +before exporting the archive file. + +This safety mechanism prevents the upgraded control plane from assuming resource +ownership before you're ready +::: + +2. Use the control plane [create command][create-command] to create a managed +control plane in Upbound: + + ```bash + up controlplane create my-controlplane + ``` + +3. Use [`up ctx`][up-ctx] to connect to the control plane created in the previous step: + + ```bash + up ctx "///my-controlplane" + ``` + + The command configures your local `kubeconfig` to connect to the control plane. + +4. Run the following command to import the archive file into the control plane: + + ```bash + up controlplane migration import --input + ``` + +:::note +The import command pauses reconciliation on managed resources, leaving the +control plane inactive. This lets you review the imported configuration before +activation. Use the `--unpause-after-import` flag to change the default behavior +and activate the control plane immediately after importing the archive file. +::: + +5. Review and validate the imported configuration/state. When you are ready, activate your managed + control plane by running the following command: + + ```bash + kubectl annotate managed --all crossplane.io/paused- + ``` + + At this point, you can delete the source Crossplane control plane. + +## CLI options + +### Filtering + +The migration tool captures the state of a Control Plane. The only filtering +supported is Kubernetes namespace and Kubernetes resource Type filtering. + +You can exclude namespaces using the `--exclude-namespaces` CLI option. This can prevent the CLI from including unwanted resources in the export. + +```bash +--exclude-namespaces=kube-system,kube-public,kube-node-lease,local-path-storage,... + +# A list of specific namespaces to exclude from the export. Defaults to 'kube-system', 'kube-public','kube-node-lease', and 'local-path-storage'. +``` + +You can exclude Kubernetes Resource types by using the `--exclude-resources` CLI option: + +```bash +--exclude-resources=EXCLUDE-RESOURCES,... + +# A list of resource types to exclude from the export in "resource.group" format. No resources are excluded by default. +``` + +For example, here's an example for excluding the CRDs installed by Crossplane functions (since they're not needed): + +```bash +up controlplane migration export \ + --exclude-resources=gotemplates.gotemplating.fn.crossplane.io,kclinputs.template.fn.crossplane.io +``` + +:::warning +You must specify resource names in lowercase "resource.group" format (for example, `gotemplates.gotemplating.fn.crossplane.io`). Using only the resource kind (for example, `GoTemplate`) isn't supported. +::: + +After export, users can also change the archive file to only include necessary resources. + +### Export non-Crossplane resources + +Use the `--include-extra-resources=` CLI option to select other CRD types to include in the export. + +### Set the kubecontext + +Currently `--context` isn't supported in the migration CLI. You should be able to use the `--kubeconfig` CLI option to use a file that's set to the correct context. For example: + +```bash +up controlplane migration export --kubeconfig +``` + +Use this in tandem with `up ctx` to export a control plane's kubeconfig: + +```bash +up ctx --kubeconfig ~/.kube/config + +# To list the current contet +up ctx . --kubeconfig ~/.kube/config +``` + +## Export archive + +The migration CLI exports an archive upon successful completion. Below is an example export of a control plane that excludes several CRD types and skips the confirmation prompt. A file gets written to the working directory, unless you select another output file: + +
+ +View the example export + +```bash +$ up controlplane migration export --exclude-resources=gotemplates.gotemplating.fn.crossplane.io,kclinputs.template.fn.crossplane.io --yes +Exporting control plane state... +✓ Scanning control plane for types to export... 121 types found! 👀 +✓ Exporting 121 Crossplane resources...60 resources exported! 📤 +✓ Exporting 3 native resources...8 resources exported! 📤 +✓ Archiving exported state... archived to "xp-state.tar.gz"! 📦 +``` + +
+ + +When an export occurs, a file named `xp-state.tar.gz` by default gets created in the working directory. You can unzip the file and all the contents of the export are all text YAML files. + +- Each CRD (for example `vpcs.ec2.aws.upbound.io`) gets its own directory +which contains: + - A `metadata.yaml` file that contains Kubernetes Object Metadata + - A list of Kubernetes Categories the resource belongs to +- A `cluster` directory that contains YAML manifests for all resources provisioned +using the CRD. + +Sample contents for a Cluster with a single `XNetwork` Composite from +[configuration-aws-network][configuration-aws-network] is show below: + + +
+ +View the example cluster content + +```bash +├── compositionrevisions.apiextensions.crossplane.io +│ ├── cluster +│ │ ├── kcl.xnetworks.aws.platform.upbound.io-4ca6a8a.yaml +│ │ └── xnetworks.aws.platform.upbound.io-9859a34.yaml +│ └── metadata.yaml +├── configurations.pkg.crossplane.io +│ ├── cluster +│ │ └── configuration-aws-network.yaml +│ └── metadata.yaml +├── deploymentruntimeconfigs.pkg.crossplane.io +│ ├── cluster +│ │ └── default.yaml +│ └── metadata.yaml +├── export.yaml +├── functions.pkg.crossplane.io +│ ├── cluster +│ │ ├── crossplane-contrib-function-auto-ready.yaml +│ │ ├── crossplane-contrib-function-go-templating.yaml +│ │ └── crossplane-contrib-function-kcl.yaml +│ └── metadata.yaml +├── internetgateways.ec2.aws.upbound.io +│ ├── cluster +│ │ └── borrelli-backup-test-xgl4q.yaml +│ └── metadata.yaml +├── mainroutetableassociations.ec2.aws.upbound.io +│ ├── cluster +│ │ └── borrelli-backup-test-t2qh7.yaml +│ └── metadata.yaml +├── namespaces +│ └── cluster +│ ├── crossplane-system.yaml +│ ├── default.yaml +│ └── upbound-system.yaml +├── providerconfigs.aws.upbound.io +│ ├── cluster +│ │ └── default.yaml +│ └── metadata.yaml +├── providerconfigusages.aws.upbound.io +│ ├── cluster +│ │ ├── 0a2a3ec6-ef13-45f9-9cf0-63af7f4a6b6b.yaml +...redacted +│ │ └── f7092b0f-3a78-4bfe-82c8-57e5085a9b11.yaml +│ └── metadata.yaml +├── providers.pkg.crossplane.io +│ ├── cluster +│ │ ├── upbound-provider-aws-ec2.yaml +│ │ └── upbound-provider-family-aws.yaml +│ └── metadata.yaml +├── routes.ec2.aws.upbound.io +│ ├── cluster +│ │ └── borrelli-backup-test-dt9cj.yaml +│ └── metadata.yaml +├── routetableassociations.ec2.aws.upbound.io +│ ├── cluster +│ │ ├── borrelli-backup-test-mr2sd.yaml +│ │ ├── borrelli-backup-test-ngq5h.yaml +│ │ ├── borrelli-backup-test-nrkgg.yaml +│ │ └── borrelli-backup-test-wq752.yaml +│ └── metadata.yaml +├── routetables.ec2.aws.upbound.io +│ ├── cluster +│ │ └── borrelli-backup-test-dv4mb.yaml +│ └── metadata.yaml +├── secrets +│ └── namespaces +│ ├── crossplane-system +│ │ ├── cert-token-signing-gateway-pub.yaml +│ │ ├── mxp-hostcluster-certs.yaml +│ │ ├── package-pull-secret.yaml +│ │ └── xgql-tls.yaml +│ └── upbound-system +│ └── aws-creds.yaml +├── securitygrouprules.ec2.aws.upbound.io +│ ├── cluster +│ │ ├── borrelli-backup-test-472f4.yaml +│ │ └── borrelli-backup-test-qftmw.yaml +│ └── metadata.yaml +├── securitygroups.ec2.aws.upbound.io +│ ├── cluster +│ │ └── borrelli-backup-test-w5jch.yaml +│ └── metadata.yaml +├── storeconfigs.secrets.crossplane.io +│ ├── cluster +│ │ └── default.yaml +│ └── metadata.yaml +├── subnets.ec2.aws.upbound.io +│ ├── cluster +│ │ ├── borrelli-backup-test-8btj6.yaml +│ │ ├── borrelli-backup-test-gbmrm.yaml +│ │ ├── borrelli-backup-test-m7kh7.yaml +│ │ └── borrelli-backup-test-nttt5.yaml +│ └── metadata.yaml +├── vpcs.ec2.aws.upbound.io +│ ├── cluster +│ │ └── borrelli-backup-test-7hwgh.yaml +│ └── metadata.yaml +└── xnetworks.aws.platform.upbound.io +├── cluster +│ └── borrelli-backup-test.yaml +└── metadata.yaml +43 directories, 87 files +``` + +
+ + +The `export.yaml` file contains metadata about the export, including the configuration of the export, Crossplane information, and what's included in the export bundle. + +
+ +View the export + +```yaml +version: v1alpha1 +exportedAt: 2025-01-06T17:39:53.173222Z +options: + excludedNamespaces: + - kube-system + - kube-public + - kube-node-lease + - local-path-storage + includedResources: + - namespaces + - configmaps + - secrets + excludedResources: + - gotemplates.gotemplating.fn.crossplane.io + - kclinputs.template.fn.crossplane.io +crossplane: + distribution: universal-crossplane + namespace: crossplane-system + version: 1.17.3-up.1 + featureFlags: + - --enable-provider-identity + - --enable-environment-configs + - --enable-composition-functions + - --enable-usages +stats: + total: 68 + nativeResources: + configmaps: 0 + namespaces: 3 + secrets: 5 + customResources: + amicopies.ec2.aws.upbound.io: 0 + amilaunchpermissions.ec2.aws.upbound.io: 0 + amis.ec2.aws.upbound.io: 0 + availabilityzonegroups.ec2.aws.upbound.io: 0 + capacityreservations.ec2.aws.upbound.io: 0 + carriergateways.ec2.aws.upbound.io: 0 + compositeresourcedefinitions.apiextensions.crossplane.io: 0 + compositionrevisions.apiextensions.crossplane.io: 2 + compositions.apiextensions.crossplane.io: 0 + configurationrevisions.pkg.crossplane.io: 0 + configurations.pkg.crossplane.io: 1 +...redacted +``` + +
+ +### Skipped resources + +Along with to the resources excluded via CLI options, the following resources aren't +included in the backup: + +- The `kube-root-ca.crt` ConfigMap, since this is cluster-specific +- Resources directly managed via Helm (ArgoCD's helm implementation, which templates +Helm resources and then applies them, get included in the backup). The migration creates the exclusion list by looking for: + - Any Resource with the label `"app.kubernetes.io/managed-by" == "Helm"` + - Kubernetes Secrets with the label prefix `helm.sh/release`. For example, `helm.sh/release.v1` +- Resources installed via a Crossplane package. These have an `ownerReference` with +a prefix `pkg.crossplane.io`. The expectation is that during import, the Crossplane Package Manager bears responsibility for installing the resources. +- Crossplane Locks: Any `Lock.pkg.crossplane.io` resource isn't included in the +export. + +## Restore + +The following is an example of a successful import run. At the end of the import, all Managed Resources are in a paused state. + +
+ +View the migration import + +```bash +$ up controlplane migration import +Importing control plane state... +✓ Reading state from the archive... Done! 👀 +✓ Importing base resources... 18 resources imported! 📥 +✓ Waiting for XRDs... Established! ⏳ +✓ Waiting for Packages... Installed and Healthy! ⏳ +✓ Importing remaining resources... 50 resources imported! 📥 +✓ Finalizing import... Done! 🎉 +``` + +
+ +Your scenario may involve migrating resources which already exist through other automation on the platform. When executing an import in these circumstances, the importer applies the new manifests to the cluster. If the resource already exists, the restore sets fields to what's in the backup. + +The importer restores all resources in the export archive. Managed Resources get imported with the `crossplane.io/paused: "true"` annotation set. Use the `--unpause-after-import` CLI argument to automatically un-pause resources that got +paused during backup, or remove the annotation manually. + +### Restore order + +The importer restores based on Kubernetes types. The restore order doesn't include parent/child relationships. + +Because Crossplane Composites create new Managed Resources if not present on the cluster, all +Claims, Composites and Managed Resources get imported in a paused state. You can un-pause them after the restore completes. + +The first step of import is installing Base Resources into the cluster. These resources (such has +packages and XRDs) must be ready before proceeding with the import. +Base Resources are: + +- Kubernetes Resources + - ConfigMaps + - Namespaces + - Secrets +- Crossplane Resources + - ControllerConfigs: `controllerconfigs.pkg.crossplane.io` + - DeploymentRuntimeConfigs: `deploymentruntimeconfigs.pkg.crossplane.io` + - StoreConfigs: `storeconfigs.secrets.crossplane.io` +- Crossplane Packages + - Providers: `providers.pkg.crossplane.io` + - Functions: `functions.pkg.crossplane.io` + - Configurations: `configurations.pkg.crossplane.io` + +Restore waits for the base resources to be `Ready` before moving on to the next step. Next, restore walks through the archive and restores all the manifests present. + +During import, the `crossplane.io/paused` annotation gets added to Managed Resources, Claims +and Composites. + +To manually un-pause managed resources after an import, remove the annotation by running: + +```bash +kubectl annotate managed --all crossplane.io/paused- +``` + +You can also run import again with the `--unpause-after-import` flag to remove the annotations. + +```bash +up controlplane migration import --unpause-after-import +``` + +### Restoring resource status + +The importer applies the status of all resources during import. The importer determines if the CRD version has a status field defined based on the stored CRD version. + + +[cli-command]: /reference/cli-reference +[up-cli]: /reference/cli-reference +[up-cli-1]: /manuals/cli/overview +[create-command]: /reference/cli-reference +[up-ctx]: /reference/cli-reference +[configuration-aws-network]: https://marketplace.upbound.io/configurations/upbound/configuration-aws-network +[spaces]: /self-hosted-spaces/overview +[uxp]: /manuals/uxp/overview diff --git a/self-hosted-spaces_versioned_docs/version-1.13/howtos/spaces-management.md b/self-hosted-spaces_versioned_docs/version-1.13/howtos/spaces-management.md index e4d504c65..55de9b3b3 100644 --- a/self-hosted-spaces_versioned_docs/version-1.13/howtos/spaces-management.md +++ b/self-hosted-spaces_versioned_docs/version-1.13/howtos/spaces-management.md @@ -201,7 +201,7 @@ kubectl delete controlplane ctp1 [up-space-init]: /reference/cli-reference -[quickstart]: / +[quickstart]: /self-hosted-spaces/self-hosted-spaces-quickstart [aws]: /self-hosted-spaces/howtos/self-hosted-spaces-deployment [azure]:/self-hosted-spaces/howtos/self-hosted-spaces-deployment [gcp]:/self-hosted-spaces/howtos/self-hosted-spaces-deployment diff --git a/self-hosted-spaces_versioned_docs/version-1.13/self-hosted-spaces-quickstart.md b/self-hosted-spaces_versioned_docs/version-1.13/self-hosted-spaces-quickstart.md index 2d00701bf..e4569d3a2 100644 --- a/self-hosted-spaces_versioned_docs/version-1.13/self-hosted-spaces-quickstart.md +++ b/self-hosted-spaces_versioned_docs/version-1.13/self-hosted-spaces-quickstart.md @@ -8,7 +8,7 @@ tier: "business" Get started with Upbound Spaces using a local `kind` cluster. This guide walks you through deploying a self-hosted Space, creating your first control plane, and optionally connecting your Space to the Upbound Console. :::info -Self-hosted Spaces are a business critical feature of Upbound and require a license token. [Contact Upbound](https://www.upbound.io/contact) if you want to try out self-hosted Spaces. +Self-hosted Spaces are a business critical feature of Upbound and require a license token. [Contact Upbound](https://www.upbound.io/contact-us) if you want to try out self-hosted Spaces. ::: ## Prerequisites diff --git a/self-hosted-spaces_versioned_docs/version-1.14/howtos/spaces-management.md b/self-hosted-spaces_versioned_docs/version-1.14/howtos/spaces-management.md index 824f9ee89..58acccd85 100644 --- a/self-hosted-spaces_versioned_docs/version-1.14/howtos/spaces-management.md +++ b/self-hosted-spaces_versioned_docs/version-1.14/howtos/spaces-management.md @@ -196,7 +196,7 @@ kubectl delete controlplane ctp1 [up-space-init]: /reference/cli-reference -[quickstart]: / +[quickstart]: /self-hosted-spaces/self-hosted-spaces-quickstart [aws]: /self-hosted-spaces/howtos/self-hosted-spaces-deployment [azure]:/self-hosted-spaces/howtos/self-hosted-spaces-deployment [gcp]:/self-hosted-spaces/howtos/self-hosted-spaces-deployment diff --git a/self-hosted-spaces_versioned_docs/version-1.14/self-hosted-spaces-quickstart.md b/self-hosted-spaces_versioned_docs/version-1.14/self-hosted-spaces-quickstart.md index 798074afb..93443d4b1 100644 --- a/self-hosted-spaces_versioned_docs/version-1.14/self-hosted-spaces-quickstart.md +++ b/self-hosted-spaces_versioned_docs/version-1.14/self-hosted-spaces-quickstart.md @@ -8,7 +8,7 @@ tier: "business" Get started with Upbound Spaces using a local `kind` cluster. This guide walks you through deploying a self-hosted Space, creating your first control plane, and optionally connecting your Space to the Upbound Console. :::info -Self-hosted Spaces are a business critical feature of Upbound and require a license token. [Contact Upbound](https://www.upbound.io/contact) if you want to try out self-hosted Spaces. +Self-hosted Spaces are a business critical feature of Upbound and require a license token. [Contact Upbound](https://www.upbound.io/contact-us) if you want to try out self-hosted Spaces. ::: ## Prerequisites diff --git a/self-hosted-spaces_versioned_docs/version-1.15/howtos/capacity-licensing.md b/self-hosted-spaces_versioned_docs/version-1.15/howtos/capacity-licensing.md index 031a65896..4466aebff 100644 --- a/self-hosted-spaces_versioned_docs/version-1.15/howtos/capacity-licensing.md +++ b/self-hosted-spaces_versioned_docs/version-1.15/howtos/capacity-licensing.md @@ -584,7 +584,7 @@ If your license shows as invalid: [CloudNativePG]: https://cloudnative-pg.io/ [backups]: https://cloudnative-pg.io/documentation/current/backup_recovery/ [backup-restore]: /self-hosted-spaces/howtos/backup-and-restore -[sales]: https://www.upbound.io/contact +[sales]: https://www.upbound.io/contact-us [eso]: https://external-secrets.io/ [Observability]: /self-hosted-spaces/howtos/observability diff --git a/self-hosted-spaces_versioned_docs/version-1.15/howtos/spaces-management.md b/self-hosted-spaces_versioned_docs/version-1.15/howtos/spaces-management.md index 42501abf5..6444a1e13 100644 --- a/self-hosted-spaces_versioned_docs/version-1.15/howtos/spaces-management.md +++ b/self-hosted-spaces_versioned_docs/version-1.15/howtos/spaces-management.md @@ -195,7 +195,7 @@ kubectl delete controlplane ctp1 [up-space-init]: /reference/cli-reference -[quickstart]: / +[quickstart]: /self-hosted-spaces/self-hosted-spaces-quickstart [aws]: /self-hosted-spaces/howtos/self-hosted-spaces-deployment [azure]:/self-hosted-spaces/howtos/self-hosted-spaces-deployment [gcp]:/self-hosted-spaces/howtos/self-hosted-spaces-deployment diff --git a/self-hosted-spaces_versioned_docs/version-1.15/self-hosted-spaces-quickstart.md b/self-hosted-spaces_versioned_docs/version-1.15/self-hosted-spaces-quickstart.md index 798074afb..93443d4b1 100644 --- a/self-hosted-spaces_versioned_docs/version-1.15/self-hosted-spaces-quickstart.md +++ b/self-hosted-spaces_versioned_docs/version-1.15/self-hosted-spaces-quickstart.md @@ -8,7 +8,7 @@ tier: "business" Get started with Upbound Spaces using a local `kind` cluster. This guide walks you through deploying a self-hosted Space, creating your first control plane, and optionally connecting your Space to the Upbound Console. :::info -Self-hosted Spaces are a business critical feature of Upbound and require a license token. [Contact Upbound](https://www.upbound.io/contact) if you want to try out self-hosted Spaces. +Self-hosted Spaces are a business critical feature of Upbound and require a license token. [Contact Upbound](https://www.upbound.io/contact-us) if you want to try out self-hosted Spaces. ::: ## Prerequisites diff --git a/self-hosted-spaces_versioned_docs/version-1.16/howtos/capacity-licensing.md b/self-hosted-spaces_versioned_docs/version-1.16/howtos/capacity-licensing.md index d5230493f..5c401f109 100644 --- a/self-hosted-spaces_versioned_docs/version-1.16/howtos/capacity-licensing.md +++ b/self-hosted-spaces_versioned_docs/version-1.16/howtos/capacity-licensing.md @@ -586,7 +586,7 @@ If your license shows as invalid: [CloudNativePG]: https://cloudnative-pg.io/ [backups]: https://cloudnative-pg.io/documentation/current/backup_recovery/ [backup-restore]: /self-hosted-spaces/howtos/backup-and-restore -[sales]: https://www.upbound.io/contact +[sales]: https://www.upbound.io/contact-us [eso]: https://external-secrets.io/ [Observability]: /self-hosted-spaces/howtos/observability diff --git a/self-hosted-spaces_versioned_docs/version-1.16/howtos/spaces-management.md b/self-hosted-spaces_versioned_docs/version-1.16/howtos/spaces-management.md index 67fee932e..057ffbe9e 100644 --- a/self-hosted-spaces_versioned_docs/version-1.16/howtos/spaces-management.md +++ b/self-hosted-spaces_versioned_docs/version-1.16/howtos/spaces-management.md @@ -199,7 +199,7 @@ kubectl delete controlplane ctp1 [up-space-init]: /reference/cli-reference -[quickstart]: / +[quickstart]: /self-hosted-spaces/self-hosted-spaces-quickstart [aws]: /self-hosted-spaces/howtos/self-hosted-spaces-deployment [azure]:/self-hosted-spaces/howtos/self-hosted-spaces-deployment [gcp]:/self-hosted-spaces/howtos/self-hosted-spaces-deployment diff --git a/self-hosted-spaces_versioned_docs/version-1.16/self-hosted-spaces-quickstart.md b/self-hosted-spaces_versioned_docs/version-1.16/self-hosted-spaces-quickstart.md index 798074afb..93443d4b1 100644 --- a/self-hosted-spaces_versioned_docs/version-1.16/self-hosted-spaces-quickstart.md +++ b/self-hosted-spaces_versioned_docs/version-1.16/self-hosted-spaces-quickstart.md @@ -8,7 +8,7 @@ tier: "business" Get started with Upbound Spaces using a local `kind` cluster. This guide walks you through deploying a self-hosted Space, creating your first control plane, and optionally connecting your Space to the Upbound Console. :::info -Self-hosted Spaces are a business critical feature of Upbound and require a license token. [Contact Upbound](https://www.upbound.io/contact) if you want to try out self-hosted Spaces. +Self-hosted Spaces are a business critical feature of Upbound and require a license token. [Contact Upbound](https://www.upbound.io/contact-us) if you want to try out self-hosted Spaces. ::: ## Prerequisites diff --git a/setup-crossplane.sh b/setup-crossplane.sh new file mode 100644 index 000000000..90eeb1008 --- /dev/null +++ b/setup-crossplane.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +for bin in kind helm kubectl; do + command -v "$bin" >/dev/null 2>&1 || { echo "error: '$bin' not found on PATH" >&2; exit 1; } +done + +echo "==> Creating kind cluster 'crossplane-source'" +kind create cluster --name crossplane-source + +echo "==> Installing Crossplane" +helm repo add crossplane-stable https://charts.crossplane.io/stable +helm repo update +helm install crossplane crossplane-stable/crossplane \ + --namespace crossplane-system --create-namespace --wait + +echo "==> Installing provider-nop and function-patch-and-transform" +kubectl apply -f - <<'EOF' +apiVersion: pkg.crossplane.io/v1 +kind: Provider +metadata: + name: provider-nop +spec: + package: xpkg.upbound.io/crossplane-contrib/provider-nop:v0.4.0 +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-patch-and-transform +spec: + package: xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.9.0 +EOF +kubectl wait --for=condition=healthy provider/provider-nop --timeout=3m +kubectl wait --for=condition=healthy function/function-patch-and-transform --timeout=3m + +echo "==> Defining a composite resource type" +kubectl apply -f - <<'EOF' +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xapps.example.upbound.io +spec: + group: example.upbound.io + names: + kind: XApp + plural: xapps + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xapps.example.upbound.io +spec: + compositeTypeRef: + apiVersion: example.upbound.io/v1alpha1 + kind: XApp + mode: Pipeline + pipeline: + - step: create-nop + functionRef: + name: function-patch-and-transform + input: + apiVersion: pt.fn.crossplane.io/v1beta1 + kind: Resources + resources: + - name: nop + base: + apiVersion: nop.crossplane.io/v1alpha1 + kind: NopResource + spec: + forProvider: + conditionAfter: + - conditionType: Ready + conditionStatus: "True" + time: 5s +EOF + +# The XRD needs a moment to establish the XApp CRD before the XR is accepted. +kubectl wait --for=condition=established crd/xapps.example.upbound.io --timeout=2m + +echo "==> Creating one composite resource" +kubectl apply -f - <<'EOF' +apiVersion: example.upbound.io/v1alpha1 +kind: XApp +metadata: + name: sample-app +spec: {} +EOF + +cat <<'EOF' + +Crossplane is running on kind cluster 'crossplane-source' with one XApp +composite resource. This is your migration source. +EOF diff --git a/src/components/CardGrid.js b/src/components/CardGrid.js new file mode 100644 index 000000000..f983c3327 --- /dev/null +++ b/src/components/CardGrid.js @@ -0,0 +1,14 @@ +import React from 'react'; + +const CardGrid = ({ sections }) => ( +
+ {sections.map((section, index) => ( + +

{section.title}

+

{section.description}

+
+ ))} +
+); + +export default CardGrid; diff --git a/src/components/CopyPageMenu.js b/src/components/CopyPageMenu.js new file mode 100644 index 000000000..6476c3389 --- /dev/null +++ b/src/components/CopyPageMenu.js @@ -0,0 +1,124 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { ArrowUpRight, Bot, Check, ChevronDown, Code2, Copy, Sparkles } from 'lucide-react'; +import styles from './CopyPageMenu.module.css'; + +const RAW_SOURCE_URL = 'https://raw.githubusercontent.com/upbound/docs/main/'; + +const AI_TARGETS = [ + { + label: 'Open in ChatGPT', + description: 'Ask ChatGPT about this page', + icon: Bot, + urlFor: (prompt) => `https://chatgpt.com/?q=${encodeURIComponent(prompt)}`, + }, + { + label: 'Open in Claude', + description: 'Ask Claude about this page', + icon: Sparkles, + urlFor: (prompt) => `https://claude.ai/new?q=${encodeURIComponent(prompt)}`, + }, + { + label: 'Open in Cursor', + description: 'Add this page as context in Cursor', + icon: Code2, + urlFor: (prompt) => `cursor://anysphere.cursor-deeplink/prompt?text=${encodeURIComponent(prompt)}`, + }, +]; + +export default function CopyPageMenu({ source, getText }) { + const [open, setOpen] = useState(false); + const [copied, setCopied] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) return undefined; + + const handlePointerDown = (event) => { + if (containerRef.current && !containerRef.current.contains(event.target)) { + setOpen(false); + } + }; + const handleKeyDown = (event) => { + if (event.key === 'Escape') setOpen(false); + }; + + document.addEventListener('mousedown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open]); + + const handleCopy = () => { + const text = getText?.(); + if (!text) return; + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setOpen(false); + setTimeout(() => setCopied(false), 2000); + }); + }; + + const sourcePath = source?.replace(/^@site\//, ''); + const rawUrl = sourcePath && `${RAW_SOURCE_URL}${sourcePath}`; + const prompt = rawUrl && `Read ${rawUrl} so you can answer questions about it. Rely only on that page.`; + + return ( +
+
+ + +
+ + {open && ( +
+ + {prompt && + AI_TARGETS.map((target) => ( + setOpen(false)} + > + + + + + + {target.label} + + + {target.description} + + + ))} +
+ )} +
+ ); +} diff --git a/src/components/CopyPageMenu.module.css b/src/components/CopyPageMenu.module.css new file mode 100644 index 000000000..962f75a85 --- /dev/null +++ b/src/components/CopyPageMenu.module.css @@ -0,0 +1,182 @@ +.container { + position: relative; + flex-shrink: 0; +} + +.trigger { + display: inline-flex; + align-items: stretch; + border: 1px solid var(--upbound-border-color); + border-radius: 0.375rem; + color: var(--brand-purple-600); + transition: border-color 0.15s ease, box-shadow 0.2s ease; +} + +.trigger:hover, +.trigger:focus-within { + border-color: var(--brand-purple-300); + box-shadow: 0 0 8px rgba(154, 94, 252, 0.25); +} + +.triggerCopy, +.triggerChevron { + display: inline-flex; + align-items: center; + border: none; + background: none; + color: inherit; + font-family: var(--ifm-font-family-base); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: color 0.15s ease; +} + +.triggerCopy { + gap: 0.375rem; + padding: 0.375rem 0.625rem; + border-radius: 0.375rem 0 0 0.375rem; +} + +.triggerCopy:hover { + color: var(--brand-purple-700); +} + +.triggerChevron { + padding: 0.375rem 0.5rem; + border-left: 1px solid var(--upbound-border-color); + border-radius: 0 0.375rem 0.375rem 0; +} + +.triggerCopy:focus-visible, +.triggerChevron:focus-visible { + outline: 2px solid var(--brand-purple-500); + outline-offset: -2px; +} + +.chevron { + color: var(--brand-purple-400); +} + +.menu { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + z-index: 10; + min-width: 16rem; + padding: 0.5rem; + border: 1px solid var(--upbound-border-color); + border-radius: 0.75rem; + background-color: var(--ifm-background-color); +} + +.menuItem { + display: flex; + align-items: center; + gap: 0.625rem; + width: 100%; + padding: 0.5rem 0.625rem; + border: 1px solid transparent; + border-radius: 0.625rem; + background: none; + color: var(--font-color-dark); + font-family: var(--ifm-font-family-base); + text-align: left; + text-decoration: none; + cursor: pointer; + transition: border-color 0.15s ease; +} + +.menuItem + .menuItem { + margin-top: 0.125rem; +} + +.iconBadge { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 1.75rem; + height: 1.75rem; + border: 1px solid var(--upbound-border-color); + border-radius: 0.375rem; + background-color: var(--upbound-light-bg); + color: var(--font-color-medium); +} + +.itemText { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; +} + +.itemTitle { + display: inline-flex; + align-items: center; + gap: 0.25rem; + color: var(--font-color-dark); + font-size: 0.8125rem; + font-weight: 600; +} + +.externalIcon { + color: var(--font-color-light); +} + +.menuItem small { + color: var(--font-color-light); + font-size: 0.75rem; + font-weight: 400; +} + +.menuItem:hover { + border-color: var(--brand-purple-200); +} + +.menuItem:hover .itemTitle { + color: var(--brand-purple-600); +} + +.menuItem:hover .iconBadge { + border-color: var(--brand-purple-200); + color: var(--brand-purple-500); +} + +html[data-theme="dark"] .trigger { + color: var(--brand-purple-200); +} + +html[data-theme="dark"] .trigger:hover, +html[data-theme="dark"] .trigger:focus-within { + box-shadow: 0 0 8px rgba(196, 167, 255, 0.3); +} + +html[data-theme="dark"] .triggerCopy:hover { + color: var(--brand-purple-100); +} + +html[data-theme="dark"] .chevron { + color: var(--brand-purple-300); +} + +html[data-theme="dark"] .menu { + background-color: var(--brand-neutral-800); +} + +html[data-theme="dark"] .iconBadge { + background-color: var(--brand-neutral-700); +} + +html[data-theme="dark"] .menuItem:hover { + border-color: var(--brand-neutral-700); +} + +html[data-theme="dark"] .menuItem:hover .itemTitle { + color: var(--brand-purple-300); +} + +html[data-theme="dark"] .menuItem:hover .iconBadge { + border-color: var(--brand-purple-300); + color: var(--brand-purple-300); +} diff --git a/src/components/GetStartedCallout.js b/src/components/GetStartedCallout.js deleted file mode 100644 index ce383a1e0..000000000 --- a/src/components/GetStartedCallout.js +++ /dev/null @@ -1,249 +0,0 @@ -// src/components/GetStartedCallout.js -import React, { useState, useEffect } from 'react'; - -const styles = ` -.get-started-section { - background: linear-gradient(135deg, var(--brand-neutral-700) 0%, var(--brand-neutral-800) 100%); - color: white; - padding: 2rem; - border-radius: 12px; - margin: 2rem 0; - box-shadow: var(--upbound-card-shadow-hover); -} -.get-started-section h2 { - margin-top: 0; - font-size: 1.8rem; - font-weight: var(--ifm-heading-font-weight); - font-family: var(--ifm-heading-font-family); - color: white; -} -.get-started-section ul { - font-size: 1.1rem; - margin: 1.5rem 0; - font-family: var(--ifm-font-family-base); -} -.get-started-section li { - margin-bottom: 0.8rem; - color: white; -} -.get-started-section a { - color: var(--brand-purple-200); - text-decoration: underline; - transition: all 0.2s ease; -} -.get-started-section a:hover { - color: var(--brand-purple-100); - text-decoration: none; -} -.code-block { - background: var(--brand-purple-900); - border: 1px solid var(--brand-neutral-700); - border-radius: 8px; - padding: 0; - margin: 1rem 0; - font-family: var(--ifm-font-family-monospace); - font-size: var(--ifm-code-font-size); - position: relative; - overflow: hidden; -} -.code-content { - padding: 1rem; - margin: 0; - background: none; - border: none; - color: var(--brand-neutral-200); - font-family: inherit; - font-size: inherit; -} -.copy-button { - position: absolute; - top: 0.5rem; - right: 0.5rem; - background: var(--brand-neutral-700); - border: 1px solid var(--brand-neutral-600); - border-radius: 6px; - color: var(--brand-neutral-200); - padding: 0.375rem 0.75rem; - font-size: 0.75rem; - font-family: var(--ifm-font-family-base); - cursor: pointer; - transition: all 0.2s ease; - display: flex; - align-items: center; - gap: 0.25rem; -} -.copy-button:hover { - background: var(--brand-neutral-600); - border-color: var(--brand-neutral-500); - color: white; -} -.copy-icon { - width: 12px; - height: 12px; -} -.installation-note { - font-style: italic; - opacity: 0.9; - margin-top: 0.5rem; - font-family: var(--ifm-font-family-base); - color: rgba(255,255,255,0.9); -} - -.tip-box { - background: rgba(154, 94, 252, 0.05); - border-left: 4px solid var(--brand-purple-500); - padding: 1rem; - margin: 1.5rem 0; - border-radius: .75rem; -} - -.tip-label { - font-weight: var(--ifm-font-weight-bold); - color: var(--brand-purple-600); - margin-bottom: 0.5rem; - font-family: var(--ifm-font-family-base); -} - -/* Dark mode */ -html[data-theme='dark'] .tip-box { - background: rgba(196, 167, 255, 0.1); - border-left-color: var(--brand-purple-300); -} - -html[data-theme='dark'] .tip-label { - color: var(--brand-purple-300); -}html[data-theme='light'] .get-started-section { - background: var(--brand-neutral-200); - color: var(--brand-neutral-900); -} -html[data-theme='light'] .get-started-section h2 { - color: var(--brand-neutral-900); -} -html[data-theme='light'] .get-started-section li { - color: var(--brand-neutral-900); -} -html[data-theme='light'] .get-started-section a { - color: var(--brand-purple-600); -} -html[data-theme='light'] .get-started-section a:hover { - color: var(--brand-purple-700); -} -html[data-theme='light'] .installation-note { - color: var(--brand-neutral-700); -} -html[data-theme='light'] .tip-label { - color: var(--brand-purple-600); -} -html[data-theme='light'] .code-block { - background: var(--brand-neutral-300); -} -html[data-theme='light'] .code-content { - color: var(--brand-neutral-900); -} -html[data-theme='light'] .copy-button { - background: var(--brand-neutral-500); - color: var(--brand-neutral-200); -} -html[data-theme='light'] .copy-button:hover { - background: var(--brand-neutral-600); - color: var(--brand-neutral-000); -} -@media (max-width: 768px) { - .get-started-section { - margin: 1rem 0; - padding: 1.5rem; - } - .get-started-section h2 { - font-size: 1.5rem; - } - .get-started-section ul { - font-size: 1rem; - } - .code-block { - font-size: 0.85rem; - } -} -`; - -function CopyIcon({ size = 12 }) { - return ( - - - - - ); -} - -function CheckIcon({ size = 12 }) { - return ( - - - - ); -} - -export function GetStarted() { - const [copied, setCopied] = useState(false); - - useEffect(() => { - // Inject styles into document head - const styleSheet = document.createElement("style"); - styleSheet.innerText = styles; - document.head.appendChild(styleSheet); - - return () => { - // Clean up styles when component unmounts - document.head.removeChild(styleSheet); - }; - }, []); - - const copyToClipboard = async () => { - try { - await navigator.clipboard.writeText('curl -sL "https://cli.upbound.io" | sh'); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy text: ', err); - } - }; - - return ( -
-

🚀 Install the Upbound CLI

-
    -
  • Install the up CLI to gain access to all Upbound's tooling on your machine.
  • -
- -
-
- curl -sL "https://cli.upbound.io" | sh -
- -
- -
- Find more installation methods on the Up CLI installation guide. -
- -
- ); -} diff --git a/src/components/GetUpboundHero.js b/src/components/GetUpboundHero.js new file mode 100644 index 000000000..eef84db5c --- /dev/null +++ b/src/components/GetUpboundHero.js @@ -0,0 +1,48 @@ +import React from 'react'; +import HeaderSVG from '@site/static/img/header.svg'; +import styles from './GetUpboundHero.module.css'; + +const GetUpboundHero = () => { + return ( +
+
+ ); +}; + +export default GetUpboundHero; diff --git a/src/components/GetUpboundHero.module.css b/src/components/GetUpboundHero.module.css new file mode 100644 index 000000000..22a236e63 --- /dev/null +++ b/src/components/GetUpboundHero.module.css @@ -0,0 +1,141 @@ +.hero { + position: relative; + display: grid; + grid-template-columns: 1.1fr 1fr; + align-items: center; + gap: 2.5rem; + overflow: hidden; + margin: 0 0 2.5rem; + padding: 2.75rem; + border-radius: 1rem; + background: linear-gradient( + 135deg, + var(--brand-purple-900) 0%, + var(--brand-neutral-800) 100% + ); +} + +.mark { + position: absolute; + top: 50%; + right: -8%; + width: 26rem; + height: auto; + transform: translateY(-50%); + opacity: 0.16; + pointer-events: none; +} + +.text { + position: relative; + z-index: 1; +} + +.eyebrow { + margin: 0 0 0.75rem; + color: var(--brand-purple-200); + font-family: var(--ifm-font-family-monospace); + font-size: 0.8rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.title { + margin: 0 0 1rem; + color: white; + font-family: var(--ifm-heading-font-family); + font-size: 2.25rem; + font-weight: 550; + line-height: 1.15; +} + +.description { + margin: 0; + max-width: 34rem; + color: rgba(255, 255, 255, 0.85); + font-size: 1.0625rem; + line-height: 1.6; +} + +.terminal { + position: relative; + z-index: 1; + overflow: hidden; + border: 1px solid var(--brand-neutral-700); + border-radius: 0.75rem; + background-color: var(--brand-neutral-800); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35); +} + +.terminalHeader { + display: flex; + gap: 0.4rem; + padding: 0.75rem 1rem; + background-color: var(--brand-neutral-700); + border-bottom: 1px solid var(--brand-neutral-600); +} + +.dot { + width: 0.65rem; + height: 0.65rem; + border-radius: 50%; +} + +.dotRed { + background-color: var(--brand-accent-red); +} + +.dotYellow { + background-color: var(--brand-accent-yellow); +} + +.dotGreen { + background-color: var(--brand-accent-green); +} + +.terminalBody { + padding: 1.25rem; + font-family: var(--ifm-font-family-monospace); + font-size: 0.8125rem; + line-height: 1.7; +} + +.command, +.output { + margin: 0 0 0.5rem; +} + +.command { + color: var(--brand-accent-green); +} + +.output { + color: var(--brand-neutral-300); +} + +@media (max-width: 900px) { + .hero { + grid-template-columns: 1fr; + padding: 2rem; + } + + .mark { + right: -20%; + width: 20rem; + } +} + +@media (max-width: 520px) { + .hero { + padding: 1.5rem; + border-radius: 0.75rem; + } + + .title { + font-size: 1.75rem; + } + + .terminalBody { + font-size: 0.75rem; + } +} diff --git a/src/components/GlobalLanguageSelector.js b/src/components/GlobalLanguageSelector.js index a13f39dd6..2b012fa52 100644 --- a/src/components/GlobalLanguageSelector.js +++ b/src/components/GlobalLanguageSelector.js @@ -1,33 +1,91 @@ -import React, { useState, useEffect, createContext, useContext } from 'react'; +import React, { useState, useEffect, useCallback, createContext, useContext } from 'react'; // Simple context for language state const LanguageContext = createContext(); +// Display labels for values that don't look right as plain toUpperCase() +const DISPLAY_LABELS = { 'kcl': 'KCL', 'python': 'Python', 'go': 'Go', 'go-templating': 'Go Templating', 'azure' : 'Azure', }; + +function displayLabel(value) { + return DISPLAY_LABELS[value] || value.toUpperCase(); +} + +// Fixed display order for language pills; anything unlisted sorts alphabetically after. +const LANGUAGE_ORDER = ['kcl', 'python', 'go', 'go-templating']; + +function sortLanguages(values) { + return [...values].sort((a, b) => { + const ia = LANGUAGE_ORDER.indexOf(a); + const ib = LANGUAGE_ORDER.indexOf(b); + if (ia === -1 && ib === -1) return a.localeCompare(b); + if (ia === -1) return 1; + if (ib === -1) return -1; + return ia - ib; + }); +} + +// Ref-counted so an option disappears once the last CodeBlock using it +// unmounts. LanguageProvider lives at the site root (src/theme/Root.js) and +// survives client-side navigation, so a plain Set that only ever grew meant +// options from a previously visited page stuck around forever -- including a +// stale *selected* language/cloud the new page never registers, which +// silently hid every CodeBlock on the page (none can match a selection that +// doesn't exist here). +export function useRefCountedSet() { + const [counts, setCounts] = useState(new Map()); + + const register = useCallback((value) => { + setCounts(prev => { + const next = new Map(prev); + next.set(value, (next.get(value) || 0) + 1); + return next; + }); + return () => { + setCounts(prev => { + const count = prev.get(value) || 0; + const next = new Map(prev); + if (count <= 1) { + next.delete(value); + } else { + next.set(value, count - 1); + } + return next; + }); + }; + }, []); + + const values = new Set(counts.keys()); + return [values, register]; +} + export function LanguageProvider({ children }) { - const [selectedLanguage, setSelectedLanguage] = useState('kcl'); - const [selectedCloud, setSelectedCloud] = useState('aws'); - const [availableLanguages, setAvailableLanguages] = useState(new Set()); - const [availableClouds, setAvailableClouds] = useState(new Set()); + const [selectedLanguage, setSelectedLanguage] = useState(null); + const [selectedCloud, setSelectedCloud] = useState(null); + const [availableLanguages, registerLanguage] = useRefCountedSet(); + const [availableClouds, registerCloud] = useRefCountedSet(); useEffect(() => { - const savedLang = localStorage.getItem('selected-language') || 'kcl'; - const savedCloud = localStorage.getItem('selected-cloud') || 'aws'; - setSelectedLanguage(savedLang); - setSelectedCloud(savedCloud); + setSelectedLanguage(localStorage.getItem('selected-language')); + setSelectedCloud(localStorage.getItem('selected-cloud')); }, []); - // Auto-select first available if current selection isn't available + // Auto-select first available (alphabetically, matching pill order) if + // current selection isn't available on this page -- including the initial + // null state, so a first-time visitor lands on the first pill instead of a + // hardcoded default. Must still correct when only one option is + // registered -- with only one choice there's no ambiguity, so there's no + // reason to leave a mismatch uncorrected. useEffect(() => { - if (availableLanguages.size > 1 && !availableLanguages.has(selectedLanguage)) { - const firstLang = Array.from(availableLanguages)[0]; + if (availableLanguages.size > 0 && !availableLanguages.has(selectedLanguage)) { + const firstLang = sortLanguages(availableLanguages)[0]; setSelectedLanguage(firstLang); localStorage.setItem('selected-language', firstLang); } }, [availableLanguages, selectedLanguage]); useEffect(() => { - if (availableClouds.size > 1 && !availableClouds.has(selectedCloud)) { - const firstCloud = Array.from(availableClouds)[0]; + if (availableClouds.size > 0 && !availableClouds.has(selectedCloud)) { + const firstCloud = Array.from(availableClouds).sort()[0]; setSelectedCloud(firstCloud); localStorage.setItem('selected-cloud', firstCloud); } @@ -43,14 +101,6 @@ export function LanguageProvider({ children }) { localStorage.setItem('selected-cloud', cloud); }; - const registerLanguage = (language) => { - setAvailableLanguages(prev => new Set([...prev, language])); - }; - - const registerCloud = (cloud) => { - setAvailableClouds(prev => new Set([...prev, cloud])); - }; - return ( {availableClouds.size > 0 && (
- - +
)} - + {availableLanguages.size > 0 && (
- - +
)} @@ -133,10 +187,15 @@ export default function GlobalLanguageSelector() { export function CodeBlock({ cloud, language, children }) { const { selectedLanguage, selectedCloud, registerLanguage, registerCloud } = useLanguageContext(); - // Register this content's options + // Register this content's options, and unregister on unmount so switching + // pages doesn't leave stale options (or a stale selection) behind. useEffect(() => { - if (language) registerLanguage(language); - if (cloud) registerCloud(cloud); + const unregisterLanguage = language ? registerLanguage(language) : undefined; + const unregisterCloud = cloud ? registerCloud(cloud) : undefined; + return () => { + unregisterLanguage?.(); + unregisterCloud?.(); + }; }, [language, cloud, registerLanguage, registerCloud]); const shouldShow = (!cloud || cloud === selectedCloud) && diff --git a/src/components/GuidesCards.js b/src/components/GuidesCards.js index 557728b7b..c18b30989 100644 --- a/src/components/GuidesCards.js +++ b/src/components/GuidesCards.js @@ -1,110 +1,24 @@ import React from 'react'; +import CardGrid from './CardGrid'; -const GuidesCards = () => { - const sections = [ - { - title: 'Intelligent Control Planes', - description: 'Advanced control plane patterns with dynamic resource composition, log analysis, and database scaling.', - link: '/guides/intelligent-control-planes/' - }, - { - title: 'Solutions', - description: 'Complete platform deployments including general IDP architecture and Upbound platform reference implementations.', - link: '/guides/solutions/get-started/' - }, - { - title: 'Use Cases', - description: 'End-to-end scenarios for applications, cloud resources, databases as a service, and managed resources.', - link: '/guides/usecases' - } - ]; +const sections = [ + { + title: 'Intelligent Control Planes', + description: 'Advanced control plane patterns with dynamic resource composition, log analysis, and database scaling.', + link: '/guides/intelligent-control-planes/intelligent-control-planes/' + }, + { + title: 'Solutions', + description: 'Complete platform deployments including general IDP architecture and Upbound platform reference implementations.', + link: '/guides/solutions/get-started/' + }, + { + title: 'Use Cases', + description: 'End-to-end scenarios for applications, cloud resources, databases as a service, and managed resources.', + link: '/guides/usecases' + } +]; - return ( - <> - - -
- {sections.map((section, index) => ( - -

- {section.title} -

-

- {section.description} -

-
- ))} -
- - ); -}; +const GuidesCards = () => ; export default GuidesCards; diff --git a/src/components/ManualsCards.js b/src/components/ManualsCards.js index 97fec27a9..e0d711276 100644 --- a/src/components/ManualsCards.js +++ b/src/components/ManualsCards.js @@ -1,135 +1,54 @@ import React from 'react'; +import CardGrid from './CardGrid'; -const ManualsCards = () => { - const sections = [ - { - title: 'Upbound Crossplane (UXP)', - description: 'Enterprise-grade Crossplane distribution with enhanced compositions, functions, operations, and package management.', - link: '/manuals/uxp/overview' - }, - { - title: 'Cloud Spaces', - description: 'Managed Crossplane control planes in the Upbound cloud environment.', - link: '/cloud-spaces/overview/' - }, - { - title: 'Self-hosted Spaces', - description: 'Managed Crossplane control planes as a self-hosted deployment.', - link: '/self-hosted-spaces/overview/' - }, - { - title: 'CLI', - description: 'Command-line tools for managing Upbound configurations, contexts, and project tooling.', - link: '/manuals/cli/overview' - }, - { - title: 'Console', - description: 'Web-based management interface with MCP Query API and self-service capabilities.', - link: '/manuals/console/upbound-console/' - }, - { - title: 'Official Packages', - description: 'Production-ready provider packages for cloud and infrastructure platforms with authentication and migration guides.', - link: '/manuals/packages/overview' - }, - { - title: 'Marketplace', - description: 'Package discovery, publishing, and repository management platform for internal and public distribution.', - link: '/manuals/marketplace/overview' - }, - { - title: 'Platform', - description: 'Identity management, RBAC, organizations, teams, and SSO integration for enterprise deployments.', - link: './platform/overview' - } - ]; - - return ( - <> - - -
- {sections.map((section, index) => ( - -

- {section.title} -

-

- {section.description} -

-
- ))} -
- - ); -}; +const sections = [ + { + title: 'Upbound hub', + description: 'Configure the central management layer for the Upbound platform.', + link: '/hub/' + }, + { + title: 'Upbound Crossplane (UXP)', + description: 'Enterprise-grade Crossplane distribution with enhanced compositions, functions, operations, and package management.', + link: '/manuals/uxp/overview' + }, + { + title: 'Cloud Spaces', + description: 'Managed Crossplane control planes in the Upbound cloud environment.', + link: '/cloud-spaces/overview/' + }, + { + title: 'Self-hosted Spaces', + description: 'Managed Crossplane control planes as a self-hosted deployment.', + link: '/self-hosted-spaces/overview/' + }, + { + title: 'Platform', + description: 'Identity management, RBAC, organizations, teams, and SSO integration for enterprise deployments.', + link: './platform/overview' + }, + { + title: 'CLI', + description: 'Command-line tools for managing Upbound configurations, contexts, and project tooling.', + link: '/manuals/cli/overview' + }, + { + title: 'Console', + description: 'Operate your control planes from one place: usage and logs, the resource explorer, the Crossplane WebUI, and the Query API.', + link: '/manuals/console/upbound-console' + }, + { + title: 'Official Packages', + description: 'Production-ready provider packages for cloud and infrastructure platforms with authentication and migration guides.', + link: '/manuals/packages/overview' + }, + { + title: 'Marketplace', + description: 'Package discovery, publishing, and repository management platform for internal and public distribution.', + link: '/manuals/marketplace/overview' + }, +]; + +const ManualsCards = () => ; export default ManualsCards; diff --git a/src/components/PlanBadge.js b/src/components/PlanBadge.js index afed2e444..724345635 100644 --- a/src/components/PlanBadge.js +++ b/src/components/PlanBadge.js @@ -89,15 +89,19 @@ export default function UpboundSidebarItem({ const pluginData = usePluginData('upbound-plan-plugin') || {}; const { planData = {} } = pluginData; - const { - items, - label, - href, - docId, - unlisted, + const { + items, + label, + href, + docId, + unlisted, linkUnlisted, - ...cleanItemProps + customProps, + ...cleanItemProps } = item; + + // A free-form status badge (e.g. "Preview"), matching the category sidebar item. + const badge = customProps?.badge; const currentPath = activePath || location.pathname; const isActive = isActiveSidebarItem(item, currentPath); @@ -161,6 +165,7 @@ export default function UpboundSidebarItem({ >
{label} + {badge && {badge}} {plan && }
{!isInternalUrl(href) && } diff --git a/src/components/Plans.module.css b/src/components/Plans.module.css index 9b0132848..8d6258d96 100644 --- a/src/components/Plans.module.css +++ b/src/components/Plans.module.css @@ -41,6 +41,29 @@ html[data-theme='dark'] .sidebarLink:global(.menu__link--active) { color: var(--brand-purple-200) !important; } +/* Status badge (e.g. "Preview") shown beside a sidebar category label */ +.statusBadge { + display: inline-flex; + align-items: center; + padding: 0.1rem 0.4rem; + border-radius: 9999px; + font-size: 0.5625rem; + font-weight: 600; + line-height: 1; + text-transform: uppercase; + letter-spacing: 0.05em; + flex-shrink: 0; + white-space: nowrap; + border: 1px solid var(--brand-purple-300); + color: var(--grape); + background: var(--brand-purple-50); +} +html[data-theme='dark'] .statusBadge { + border-color: var(--brand-purple-300); + color: var(--brand-purple-200); + background: rgba(196, 167, 255, 0.15); +} + /* Plan Badge Base Styles*/ .planBadge { display: inline-flex; diff --git a/src/components/ReferenceCards.js b/src/components/ReferenceCards.js index 627cd6c50..17e9ebec7 100644 --- a/src/components/ReferenceCards.js +++ b/src/components/ReferenceCards.js @@ -1,130 +1,44 @@ import React from 'react'; +import CardGrid from './CardGrid'; -const ReferenceCards = () => { - const sections = [ - { - title: 'APIs', - description: 'Crossplane API, Query API, Spaces API, UXP API, and Project & Testing API with CRD specifications.', - link: '/reference/apis' - }, - { - title: 'CLI Reference', - description: 'Command-line interface documentation with complete command reference and usage examples.', - link: '/reference/cli-reference' - }, - { - title: 'Spaces Helm Reference', - description: 'Helm chart configuration and deployment reference documentation for Upbound Spaces.', - link: '/self-hosted-spaces/reference/' - }, - { - title: 'UXP Helm Reference', - description: 'Helm chart configuration and deployment reference documentation for UXP.', - link: '/reference/uxp-helm-reference' - }, - { - title: 'Release Notes', - description: 'Latest updates and changes for Spaces, Managed Control Plane Connector, and Up CLI with version history.', - link: '/reference/release-notes' - }, - { - title: 'CVE Policy', - description: 'How Upbound identifies, prioritizes, and remediates CVEs across the Upbound Platform.', - link: '/reference/cve-policy' - }, - { - title: 'Usage & Operations', - description: 'Feature lifecycle, licensing, telemetry, support information, and VS Code extensions.', - link: '/reference/usage' - } - ]; +const sections = [ + { + title: 'APIs', + description: 'Crossplane API, Query API, Spaces API, UXP API, and Project & Testing API with CRD specifications.', + link: '/reference/apis' + }, + { + title: 'CLI Reference', + description: 'Command-line interface documentation with complete command reference and usage examples.', + link: '/reference/cli-reference' + }, + { + title: 'Spaces Helm Reference', + description: 'Helm chart configuration and deployment reference documentation for Upbound Spaces.', + link: '/self-hosted-spaces/reference/' + }, + { + title: 'UXP Helm Reference', + description: 'Helm chart configuration and deployment reference documentation for UXP.', + link: '/reference/uxp-helm-reference' + }, + { + title: 'Release Notes', + description: 'Latest updates and changes for Spaces, Managed Control Plane Connector, and Up CLI with version history.', + link: '/reference/release-notes' + }, + { + title: 'CVE Policy', + description: 'How Upbound identifies, prioritizes, and remediates CVEs across the Upbound Platform.', + link: '/reference/cve-policy' + }, + { + title: 'Usage & Operations', + description: 'Feature lifecycle, licensing, telemetry, support information, and VS Code extensions.', + link: '/reference/usage' + } +]; - return ( - <> - - -
- {sections.map((section, index) => ( - -

- {section.title} -

-

- {section.description} -

-
- ))} -
- - ); -}; +const ReferenceCards = () => ; export default ReferenceCards; diff --git a/src/components/VersionSelector.js b/src/components/VersionSelector.js index b1bf4877c..09112e5c7 100644 --- a/src/components/VersionSelector.js +++ b/src/components/VersionSelector.js @@ -1,120 +1,75 @@ import React, { createContext, useState, useContext, useEffect } from 'react'; +import { useRefCountedSet } from './GlobalLanguageSelector'; -let globalSelectedVersion = 'v1'; -let globalAvailableVersions = new Set(); -let globalInitialized = false; -let globalUpdateCallbacks = new Set(); - -// Create the context const VersionContext = createContext(); -// Create a provider component export function VersionProvider({ children }) { const [selectedVersion, setSelectedVersion] = useState('v1'); - const [availableVersions, setAvailableVersions] = useState(new Set()); - const [initialized, setInitialized] = useState(false); + const [availableVersions, registerVersion] = useRefCountedSet(); + + useEffect(() => { + setSelectedVersion(localStorage.getItem('selected-version') || 'v1'); + }, []); + + useEffect(() => { + if (availableVersions.size > 0 && !availableVersions.has(selectedVersion)) { + const firstVersion = Array.from(availableVersions)[0]; + setSelectedVersion(firstVersion); + localStorage.setItem('selected-version', firstVersion); + } + }, [availableVersions, selectedVersion]); + + const updateVersion = (version) => { + setSelectedVersion(version); + localStorage.setItem('selected-version', version); + }; return ( - + {children} ); } -// Custom hook to use the version context export function useVersionContext() { return useContext(VersionContext); } - -const initializeFromStorage = () => { - if (typeof window !== 'undefined' && !globalInitialized) { - const savedVersion = localStorage.getItem('selected-version') || 'v1'; - globalSelectedVersion = savedVersion; - globalInitialized = true; - } -}; - -const updateGlobalVersion = (version) => { - globalSelectedVersion = version; - if (typeof window !== 'undefined') { - localStorage.setItem('selected-version', version); - } - globalUpdateCallbacks.forEach(callback => callback()); -}; - -const registerGlobalVersion = (version) => { - globalAvailableVersions.add(version); - globalUpdateCallbacks.forEach(callback => callback()); -}; - -// Single component that does everything export default function VersionSelector({ version, children }) { - const [, forceUpdate] = useState({}); - - // Initialize on first mount - useEffect(() => { - initializeFromStorage(); - - // Register for updates - const callback = () => forceUpdate({}); - globalUpdateCallbacks.add(callback); - - return () => globalUpdateCallbacks.delete(callback); - }, []); + const { selectedVersion, availableVersions, updateVersion, registerVersion } = useVersionContext(); - // Register this version if provided useEffect(() => { - if (version) { - registerGlobalVersion(version); - } - }, [version]); + if (version) return registerVersion(version); + }, [version, registerVersion]); - // Auto-select first available if current selection isn't available - useEffect(() => { - if (globalAvailableVersions.size > 1 && !globalAvailableVersions.has(globalSelectedVersion)) { - const firstVersion = Array.from(globalAvailableVersions)[0]; - updateGlobalVersion(firstVersion); - } - }); + if (version) { + return version === selectedVersion ? <>{children} : null; + } - // If no version prop, show selector - if (!version) { - if (globalAvailableVersions.size === 0) { - return null; - } - - return ( -
-
-
- - + if (availableVersions.size === 0) { + return null; + } + + return ( +
+
+
+ Version +
+ {Array.from(availableVersions).sort().map(v => ( + + ))}
- ); - } - - // If version prop provided, show/hide content - return version === globalSelectedVersion ? <>{children} : null; +
+ ); } diff --git a/src/css/custom.css b/src/css/custom.css index 5f585514d..e3feb9bb6 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -323,14 +323,14 @@ article ol:not([role]) { list-style-type: decimal; } -article a:not(.card):not(.menu__link):not(.navbar__item):not(.table-of-contents__link):not(.cta-button) { +article a:not(.card):not(.menu__link):not(.navbar__item):not(.table-of-contents__link):not(.cta-button):not(.menu-item-link) { color: var(--grape); text-decoration: none; border-bottom: 1px solid var(--grape); transition: all 0.2s ease; } -article a:not(.card):not(.menu__link):not(.navbar__item):not(.table-of-contents__link):not(.cta-button):hover { +article a:not(.card):not(.menu__link):not(.navbar__item):not(.table-of-contents__link):not(.cta-button):not(.menu-item-link):hover { color: var(--link-hover-color); border-bottom-color: var(--link-hover-color); border-bottom-width: 2px; @@ -365,8 +365,8 @@ article a:not(.card):not(.menu__link):not(.navbar__item):not(.table-of-contents_ } .menu__list .menu__list { - margin-left: 1rem !important; - padding-left: 1rem; + margin-left: .5rem !important; + padding-left: .5rem; border-left: 2px solid var(--upbound-border-color); } @@ -951,666 +951,6 @@ html[data-theme="light"] .footer-links a:hover { html[data-theme="light"] .footer-copyright { color: var(--brand-neutral-500); } - -/* ============================================================ */ -/* LANDING PAGE COMPONENTS */ -/* ============================================================ */ - -/* Landing Page Layout */ -.landing-page { - min-height: calc(100vh - var(--ifm-navbar-height)); - animation: fadeInUp 0.6s ease-out; - background: linear-gradient( - 135deg, - var(--brand-neutral-100) 0%, - var(--brand-purple-50) 100% - ) !important; -} - -.landing-page .container { - max-width: 1200px; - margin: 0 auto; - padding: 0 1.5rem; -} - -/* Dark mode landing page */ -html[data-theme="dark"] .landing-page { - background: linear-gradient( - 135deg, - var(--brand-purple-900) 0%, - var(--brand-neutral-800) 100% - ) !important; -} - -/* Hero Section */ -.hero-section { - position: relative; - z-index: 1; - padding: 3rem 0; - display: flex; - align-items: center; -} - -/* Keep hero section the same in light mode */ -html[data-theme="light"] .hero-section { - background: linear-gradient( - 135deg, - var(--brand-purple-900) 0%, - var(--brand-neutral-800) 100% - ) !important; -} - -.hero-content { - max-width: 1200px; - margin: 0 auto; - padding: 0 1.5rem; - width: 100%; -} - -.hero-text { - text-align: center; - display: flex; - flex-direction: column; - align-items: center; - gap: 1.5rem; -} - -.hero-svg-container { - margin-bottom: 0; - display: flex; - justify-content: center; -} - -.hero-svg { - width: 100%; - max-width: 600px; - height: auto; - filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.2)); - transition: transform 0.3s ease; -} - -.hero-svg:hover { - transform: scale(1.02); -} - -.hero-title { - font-size: 44px; - font-weight: 550; - color: white !important; - margin: 0; - line-height: 1.1; - font-family: var(--ifm-heading-font-family); - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); -} - -.hero-subtitle { - display: block; - font-size: 24px; - font-weight: 700; - color: var(--brand-purple-100) !important; - margin-top: 0.5rem; -} - -.hero-description { - font-size: 1.25rem; - color: rgba(255, 255, 255, 0.9) !important; - margin: 0; - max-width: 48rem; - line-height: 1.6; -} - -.hero-buttons { - display: flex; - flex-direction: row; - gap: 1rem; - align-items: center; - justify-content: center; -} - -.hero-button { - padding: 1rem 2rem; - border-radius: 0.75rem; - font-weight: 600; - font-size: 1.125rem; - transition: all 0.3s ease; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - border: none; - cursor: pointer; - min-width: 200px; - text-decoration: none; -} - -.hero-button--primary { - background-color: white; - color: var(--brand-purple-600); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); -} - -.hero-button--primary:hover { - background-color: var(--brand-neutral-100); - transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); - color: var(--brand-purple-600); - text-decoration: none; -} - -.hero-button--secondary { - border: 2px solid rgba(255, 255, 255, 0.8); - color: white; - background-color: transparent; -} - -.hero-button--secondary:hover { - background-color: rgba(255, 255, 255, 0.1); - transform: translateY(-2px); - color: white; - text-decoration: none; - border-color: white; -} - -.button-icon { - width: 1.25rem; - height: 1.25rem; - transition: transform 0.2s ease; -} - -.hero-button:hover .button-icon { - transform: translateX(2px); -} - -/* Quick Start Section */ -.quickstart-section { - padding: 4rem 0; - color: white; -} - -.quickstart-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 3rem; - align-items: center; -} - -.quickstart-title { - font-size: 2.5rem; - font-weight: 500; - margin-bottom: 1.5rem; - font-family: var(--ifm-heading-font-family); - color: white !important; -} - -.quickstart-description { - font-size: 1.25rem; - color: var(--brand-neutral-200) !important; - margin-bottom: 2rem; - line-height: 1.6; -} - -.quickstart-buttons { - display: flex; - flex-direction: row; - gap: 1rem; -} - -.quickstart-button { - padding: 1rem 2rem; - border-radius: 0.75rem; - font-weight: 600; - font-size: 1.125rem; - transition: all 0.3s ease; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - border: none; - cursor: pointer; - min-width: 200px; - text-decoration: none; -} - -.quickstart-button--primary { - background-color: var(--brand-purple-500); - color: white; -} - -.quickstart-button--primary:hover { - background-color: var(--brand-purple-400); - transform: translateY(-2px); - color: white; - text-decoration: none; -} - -.quickstart-button--secondary { - border: 2px solid var(--brand-purple-300); - color: var(--brand-purple-100); - background-color: transparent; -} - -.quickstart-button--secondary:hover { - background-color: rgba(196, 167, 255, 0.1); - transform: translateY(-2px); - color: var(--brand-purple-100); - text-decoration: none; -} - -/* Light mode quickstart adjustments */ -html[data-theme="light"] .quickstart-title { - color: var(--brand-neutral-900) !important; -} - -html[data-theme="light"] .quickstart-description { - color: var(--brand-neutral-700) !important; -} - -html[data-theme="light"] .quickstart-button--secondary { - border-color: var(--brand-purple-600); - color: var(--brand-purple-700); -} - -html[data-theme="light"] .quickstart-button--secondary:hover { - background-color: rgba(154, 94, 252, 0.1); - color: var(--brand-purple-800); -} - -/* Terminal Window */ -.terminal-window { - background-color: var(--brand-neutral-800); - border-radius: 0.75rem; - border: 1px solid var(--brand-neutral-700); - overflow: hidden; - box-shadow: var(--upbound-card-shadow); -} - -.terminal-header { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 1rem 1.5rem; - background-color: var(--brand-neutral-700); - border-bottom: 1px solid var(--brand-neutral-600); -} - -.terminal-controls { - display: flex; - gap: 0.5rem; -} - -.terminal-dot { - width: 0.75rem; - height: 0.75rem; - border-radius: 50%; -} - -.terminal-dot--red { - background-color: var(--brand-accent-red); -} -.terminal-dot--yellow { - background-color: var(--brand-accent-yellow); -} -.terminal-dot--green { - background-color: var(--brand-accent-green); -} - -.terminal-title { - color: var(--brand-neutral-400); - font-size: 0.875rem; - margin-left: 0.5rem; -} - -.terminal-content { - padding: 1.5rem; - font-family: var(--ifm-font-family-monospace); - font-size: 0.875rem; - line-height: 1.6; -} - -.terminal-line { - margin-bottom: 0.5rem; -} - -.terminal-command { - color: var(--brand-accent-green); -} - -.terminal-output { - color: var(--brand-neutral-300); -} - -/* Light mode terminal */ -html[data-theme="light"] .terminal-command { - color: var(--brand-sapphire) !important; -} - -html[data-theme="light"] .terminal-output { - color: var(--brand-neutral-300) !important; -} - -/* Features Section */ -.features-section { - padding: 4rem 0; -} - -.section-header { - text-align: center; - margin-bottom: 4rem; -} - -.section-title { - font-size: 2.5rem; - font-weight: 500; - color: var(--font-color-dark); - margin-bottom: 1rem; - font-family: var(--ifm-heading-font-family); -} - -.section-description { - font-size: 1.25rem; - color: var(--font-color-medium); - max-width: 48rem; - margin: 0 auto; - line-height: 1.6; -} - -.features-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 2rem; - margin-bottom: 4rem; -} - -.feature-card { - background-color: var(--ifm-background-color); - border-radius: 0.75rem; - padding: 2rem; - box-shadow: var(--upbound-card-shadow); - border: 1px solid var(--upbound-border-color); - transition: all 0.3s ease; - height: 100%; -} - -.feature-card:hover { - transform: translateY(-4px); - box-shadow: var(--upbound-card-shadow-hover); -} - -.feature-icon { - color: var(--brand-purple-500); - margin-bottom: 1rem; -} - -.feature-icon svg { - width: 1.5rem; - height: 1.5rem; -} - -.feature-title { - font-size: 1.25rem; - font-weight: 450; - color: var(--font-color-dark); - margin-bottom: 0.75rem; - font-family: var(--ifm-heading-font-family); -} - -.feature-description { - color: var(--font-color-medium); - line-height: 1.6; - margin: 0; -} - -/* Dark mode features */ -html[data-theme="dark"] .feature-card { - background-color: var(--brand-neutral-800) !important; - border-color: var(--brand-neutral-700) !important; -} - -html[data-theme="dark"] .section-title { - color: var(--brand-neutral-100) !important; -} - -html[data-theme="dark"] .section-description, -html[data-theme="dark"] .feature-description { - color: var(--brand-neutral-300) !important; -} - -html[data-theme="dark"] .feature-title { - color: var(--brand-neutral-100) !important; -} - -/* Light mode features */ -html[data-theme="light"] .feature-card { - background-color: white !important; - border-color: var(--brand-neutral-300) !important; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08) !important; -} - -html[data-theme="light"] .section-title { - color: var(--brand-neutral-900) !important; -} - -html[data-theme="light"] .section-description, -html[data-theme="light"] .feature-description { - color: var(--brand-neutral-600) !important; -} - -html[data-theme="light"] .feature-title { - color: var(--brand-neutral-700) !important; -} - -/* Documentation Sections */ -.docs-section { - padding: 4rem 0; -} - -html[data-theme="light"] .docs-section { - background-color: var(--brand-neutral-100) !important; -} - -.docs-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 1rem; -} - -.doc-card { - background-color: var(--ifm-background-color); - border-radius: 1rem; - box-shadow: var(--upbound-card-shadow); - border: 1px solid var(--upbound-border-color); - overflow: hidden; - transition: all 0.3s ease; - height: 100%; - display: flex; - flex-direction: column; - min-width: 0; - width: 100%; -} - -.doc-card:hover { - transform: translateY(-4px); - box-shadow: var(--upbound-card-shadow-hover); -} - -.doc-card-header { - padding: 1.5rem; - color: white; -} - -.doc-card-header.getstarted { - background: linear-gradient( - 135deg, - var(--brand-purple-500) 0%, - var(--brand-purple-900) 100% - ); -} - -.doc-card-header.guides { - background: linear-gradient( - 135deg, - var(--brand-purple-500) 0%, - var(--brand-sapphire) 100% - ); -} - -.doc-card-header.manuals { - background: linear-gradient( - 135deg, - var(--brand-purple-500) 0%, - var(--brand-magenta) 100% - ); -} - -.doc-card-header.reference { - background: linear-gradient( - 135deg, - var(--brand-purple-500) 0%, - var(--brand-spruce) 100% - ); -} - -.doc-card-icon { - margin-bottom: 1rem; -} - -.doc-card-icon svg { - width: 2rem; - height: 2rem; -} - -.doc-card-title { - font-size: 1.5rem; - font-weight: 650; - margin-bottom: 0.5rem; - font-family: var(--ifm-heading-font-family); - color: var(--brand-neutral-100) !important; -} - -.doc-card-description { - opacity: 0.9; - font-weight: 600; - line-height: 1.6; - margin: 0; - color: var(--brand-neutral-200) !important; -} - -.doc-card-body { - padding: 1.5rem; - flex-grow: 1; - display: flex; - flex-direction: column; -} - -.doc-card-list { - list-style: none; - padding: 0; - margin: 0 0 1.5rem 0; - flex-grow: 1; -} - -.doc-card-item { - display: flex; - align-items: flex-start; - gap: 0.75rem; - font-weight: 600; - margin-bottom: 0.75rem; - color: var(--font-color-medium); - line-height: 1.5; -} - -.doc-card-item-icon { - width: 1rem; - height: 1rem; - color: var(--brand-neutral-400); - margin-top: 0.125rem; - flex-shrink: 0; -} - -.doc-card-button { - width: 100%; - padding: 0.75rem 1.5rem; - border-radius: 0.5rem; - font-weight: 600; - border: none; - cursor: pointer; - transition: all 0.3s ease; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - color: white; - background: linear-gradient( - 135deg, - var(--brand-purple-500), - var(--brand-purple-600) - ); - text-decoration: none; -} - -.doc-card-button.getstarted { - background: linear-gradient( - 135deg, - var(--brand-purple-500) 0%, - var(--brand-purple-900) 100% - ); -} - -.doc-card-button.guides { - background: linear-gradient( - 135deg, - var(--brand-purple-500) 0%, - var(--brand-sapphire) 100% - ); -} - -.doc-card-button.manuals { - background: linear-gradient( - 135deg, - var(--brand-purple-500) 0%, - var(--brand-magenta) 100% - ); -} - -.doc-card-button.reference { - background: linear-gradient( - 135deg, - var(--brand-purple-500) 0%, - var(--brand-spruce) 100% - ); -} - -.doc-card-button:hover { - transform: scale(1.02); - color: white; - text-decoration: none; -} - -.doc-card-button:hover .button-icon { - transform: translateX(2px); -} - -/* Dark mode docs */ -html[data-theme="dark"] .doc-card { - background-color: var(--brand-neutral-800) !important; - border-color: var(--brand-neutral-700) !important; -} - -html[data-theme="dark"] .doc-card-item { - color: var(--brand-neutral-300) !important; -} - -/* Light mode docs */ -html[data-theme="light"] .doc-card { - background-color: white !important; - border-color: var(--brand-neutral-300) !important; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08) !important; -} - -html[data-theme="light"] .doc-card-item { - color: var(--brand-neutral-600) !important; -} - /* ============================================================ */ /* FORM COMPONENTS - Language and Cloud Selector */ /* ============================================================ */ @@ -1619,64 +959,70 @@ html[data-theme="light"] .doc-card-item { background-color: var(--ifm-background-color); border: 1px solid var(--upbound-border-color); border-radius: 0.75rem; - padding: 1.5rem; + padding: 1rem 1.25rem; margin: 0 0 2rem 0; - box-shadow: var(--upbound-card-shadow); } .selector-controls { display: flex; - gap: 1.5rem; - align-items: baseline; + flex-wrap: wrap; + gap: 1.5rem 2rem; + align-items: center; } .selector-group { display: flex; - flex-direction: column; - gap: 0.5rem; + align-items: center; + gap: 0.75rem; } -.selector-group label { +.selector-group-label { font-weight: 600; - font-size: 0.875rem; + font-size: 0.8rem; color: var(--font-color-medium); - line-height: 1.2; - margin: 0 0 0.5rem 0; + white-space: nowrap; } -.cloud-select, -.language-select { - padding: 0.75rem 1rem; +.selector-pills { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; +} + +.selector-pill { + appearance: none; border: 1px solid var(--upbound-border-color); - border-radius: 0.5rem; - background-color: var(--ifm-background-color); - color: var(--ifm-font-color-base); - font-size: 0.9rem; + border-radius: 0.375rem; + background: transparent; + color: var(--font-color-medium); + font-size: 0.8rem; font-family: var(--ifm-font-family-base); + font-weight: 500; + padding: 0.4rem 0.9rem; cursor: pointer; - width: 160px; - height: 44px; - box-sizing: border-box; - appearance: none; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236C6F7E' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); - background-position: right 0.75rem center; - background-repeat: no-repeat; - background-size: 1.25rem; - padding-right: 2.5rem; - transition: all 0.2s ease; + transition: border-color 0.15s ease, box-shadow 0.2s ease, color 0.15s ease; } -.cloud-select:hover, -.language-select:hover { - border-color: var(--grape); - background-color: var(--upbound-light-bg); +.selector-pill:hover { + color: var(--brand-purple-700); + border-color: var(--brand-purple-300); + box-shadow: 0 0 8px rgba(154, 94, 252, 0.25); +} + +.selector-pill[aria-pressed="true"] { + color: var(--brand-purple-600); + border-color: var(--brand-purple-300); + box-shadow: 0 0 8px rgba(154, 94, 252, 0.25); +} + +html[data-theme="dark"] .selector-pill:hover { + color: var(--brand-purple-100); + box-shadow: 0 0 8px rgba(196, 167, 255, 0.3); } -.cloud-select:focus, -.language-select:focus { - outline: none; - border-color: var(--grape); - box-shadow: 0 0 0 3px rgba(154, 94, 252, 0.1); +html[data-theme="dark"] .selector-pill[aria-pressed="true"] { + color: var(--brand-purple-200); + box-shadow: 0 0 8px rgba(196, 167, 255, 0.3); } /* ============================================================ */ @@ -1748,14 +1094,6 @@ html[data-theme="dark"] code[class*="language-"] { /* ============================================================ */ @media (max-width: 996px) { - .hero-section { - padding: 2.5rem 0; - } - - .hero-svg { - max-width: 500px; - } - .navbar { position: relative !important; backdrop-filter: none !important; @@ -1775,72 +1113,6 @@ html[data-theme="dark"] code[class*="language-"] { } @media (max-width: 768px) { - .hero-title { - font-size: 2.5rem; - } - - .hero-subtitle { - font-size: 1.75rem; - } - - .hero-description { - font-size: 1.125rem; - } - - .hero-buttons, - .quickstart-buttons { - flex-direction: column; - align-items: stretch; - } - - .hero-text { - padding: 0; - gap: 1rem; - } - - .hero-section { - padding: 2rem 0; - } - - .hero-svg { - max-width: 420px; - } - - .hero-svg-container { - margin-bottom: 0; - } - - .section-title { - font-size: 2rem; - } - - .section-description { - font-size: 1.125rem; - } - - .features-grid, - .docs-grid { - grid-template-columns: 1fr; - } - - .quickstart-grid { - grid-template-columns: 1fr; - gap: 2rem; - } - - .quickstart-title { - font-size: 2rem; - } - - .quickstart-description { - font-size: 1.125rem; - } - - .terminal-content { - font-size: 0.75rem; - padding: 1rem; - } - .footer-links { gap: 1rem; flex-direction: column; @@ -1848,22 +1120,7 @@ html[data-theme="dark"] code[class*="language-"] { .selector-controls { flex-direction: column; - align-items: stretch; - } - - .cloud-select, - .language-select { - width: 100%; - } -} - -@media (max-width: 480px) { - .hero-section { - padding: 1.5rem 0; - } - - .hero-svg { - max-width: 380px; + align-items: flex-start; } } @@ -1993,89 +1250,66 @@ a.breadcrumbs__link svg { margin: 0 auto !important; } -/* ============================================================ */ -/* COPY MARKDOWN BUTTON */ -/* ============================================================ */ - -.copy-markdown-button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - margin-bottom: 1.5rem; - border: 1px solid var(--upbound-border-color); - border-radius: 0.375rem; - background-color: var(--ifm-background-color); - color: var(--font-color-medium); - font-family: var(--ifm-font-family-base); - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); -} - -.copy-markdown-button:hover { - border-color: var(--brand-purple-500); - color: var(--brand-purple-600); - background-color: var(--upbound-light-bg); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); -} - -.copy-markdown-button:active { - transform: scale(0.98); +.theme-doc-version-badge { + display: none; } -.copy-markdown-button:focus { - outline: none; - border-color: var(--brand-purple-500); - box-shadow: 0 0 0 3px rgba(154, 94, 252, 0.1); -} +/* ============================================================ */ +/* DOCUMENTATION CARD GRID (CardGrid.js) */ +/* ============================================================ */ -/* Copied state */ -.copy-markdown-button.copied { - background-color: rgba(154, 94, 252, 0.1); - border-color: var(--brand-purple-500); - color: var(--brand-purple-600); +.documentation-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1.5rem; + margin: 2rem auto; + max-width: 1200px; } -.copy-markdown-button.copied:hover { - background-color: rgba(154, 94, 252, 0.15); - border-color: var(--brand-purple-500); - color: var(--brand-purple-600); +.documentation-card { + display: block; + padding: 1.75rem; + border: 1px solid var(--upbound-border-color) !important; + border-radius: 0.75rem; + text-decoration: none !important; + background-color: var(--ifm-background-color); + transition: all 0.3s ease; + height: 100%; } -/* Hidden state - always render but hide with CSS to avoid hydration errors */ -.copy-markdown-button.hidden { - display: none; +.documentation-card:hover { + border-color: var(--ifm-color-primary); + transform: translateY(-2px); + text-decoration: none !important; + color: inherit; } -/* Dark mode styles */ -html[data-theme="dark"] .copy-markdown-button { - border-color: var(--brand-neutral-700); - color: var(--brand-neutral-300); - background-color: var(--brand-neutral-800); +.documentation-card-title { + margin: 0 0 0.75rem 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--ifm-font-color-base); + font-family: var(--ifm-heading-font-family); + line-height: 1.3; } -html[data-theme="dark"] .copy-markdown-button:hover { - border-color: var(--brand-purple-300); - color: var(--brand-purple-300); - background-color: rgba(196, 167, 255, 0.1); +.documentation-card-description { + margin: 0; + font-size: 0.9rem; + line-height: 1.5; + color: var(--ifm-color-content-secondary); } -html[data-theme="dark"] .copy-markdown-button.copied { - background-color: rgba(196, 167, 255, 0.15); - border-color: var(--brand-purple-300); - color: var(--brand-purple-300); -} +@media (max-width: 600px) { + .documentation-cards-grid { + gap: 1rem; + } -html[data-theme="dark"] .copy-markdown-button.copied:hover { - background-color: rgba(196, 167, 255, 0.2); - border-color: var(--brand-purple-300); - color: var(--brand-purple-300); -} + .documentation-card { + padding: 1.25rem; + } -.theme-doc-version-badge { - display: none; + .documentation-card-title { + font-size: 1.125rem; + } } diff --git a/src/pages/index.js b/src/pages/index.js deleted file mode 100644 index 3bea9b20a..000000000 --- a/src/pages/index.js +++ /dev/null @@ -1,307 +0,0 @@ -import React from "react"; -import { Link } from "react-router-dom"; -import Layout from "@theme/Layout"; -import Head from "@docusaurus/Head"; -import { - ChevronRight, - Zap, - Shield, - Code, - BookOpen, - Settings, - Target, - Rocket, -} from "lucide-react"; -import HeaderSVG from "@site/static/img/header.svg"; - -const LandingPage = () => { - const sections = [ - { - id: "getstarted", - title: "Get Started", - description: - "Quick introduction to Upbound Crossplane and building your first control plane", - icon: , - items: [ - "Prerequisites and setup", - "Create a control plane project", - "Define custom resource types", - "Deploy and use resources", - ], - }, - { - id: "guides", - title: "Guides", - description: - "Step-by-step tutorials for platform engineering scenarios and integrations", - icon: , - items: [ - "Build control plane projects", - "Create an Internal Developer Platform", - "Common use cases", - ], - }, - { - id: "manuals", - title: "Manuals", - description: "Comprehensive documentation for Upbound Crossplane", - icon: , - items: [ - "Install, configure, and manage Upbound", - "Control Plane Concepts", - "How-to deploy Upbound in production", - ], - }, - { - id: "reference", - title: "Reference", - description: - "API documentation, CLI commands, Release Notes, and technical specifications", - icon: , - items: [ - "Crossplane API", - "Spaces API", - "CLI reference", - "Release notes", - ], - }, - ]; - - const features = [ - { - icon: , - title: "AI-Native Distribution", - description: - "Built for autonomous infrastructure platforms serving both humans and AI systems", - link: "/guides/intelligent-control-planes", - }, - { - icon: , - title: "Control Plane Pattern", - description: - "Software that controls other software through declarative APIs and continuous reconciliation", - link: "/manuals/cli/howtos/project", - }, - { - icon: , - title: "Custom APIs", - description: - "Build your own platform APIs with Custom Resource Definitions and composition functions", - link: "/manuals/uxp/concepts/composition/overview", - }, - ]; - - return ( -
- {/* Hero Section */} -
-
-
-
- -
-

Upbound

-

- Welcome to Upbound Crossplane, the AI-native - distribution of Crossplane. -

-

- Build autonomous infrastructure platforms ready for - the age of autonomous systems. -

-
-
-
- {/* Quick Start Section */} -
-
-
-
-

- Ready to Get Started? -

-

- Create your first control plane project in just - 10 minutes. Our quickstart guide walks you - through creating a custom resource type and - deploying it to a local Upbound Crossplane - instance. -

- -
- -
-
-
-
-
-
-
-
-
-
- $ curl -sL "https://cli.upbound.io" | sh -
-
- $ sudo mv up /usr/local/bin/ -
-
- $ up project init -t - project-template-k8s-webapp -l python - my-webapp -
- -
- ✓ Created control plane project -
-
- $ cd my-webapp && up project run --local --ingress -
-
- {" "} - 💻 Local dev control plane running in kind cluster "my-webapp" -
-
- $ up uxp web-ui open -
- - -
-
-
-
-
-
- - {/* Documentation Sections */} -
-
-
-

- Your Control Plane Journey -

-

- Get started and build your best platform. -

-
- -
- {sections.map((section) => ( -
-
-
- {section.icon} -
-

- {section.title} -

-

- {section.description} -

-
- -
-
    - {section.items.map((item, index) => ( -
  • - - {item} -
  • - ))} -
- - - Explore {section.title} - - -
-
- ))} -
-
-
- {/* Features Section */} -
-
-
-
-

- Why Control Planes? -

-

- Control planes unlock the benefits of building - custom APIs to manage the resources your users - need. -

-
-
- {features.map((feature, index) => ( - -
- {feature.icon} -
-

- {feature.title} -

-

- {feature.description} -

- - ))} -
-
-
{" "} -
-
- ); -}; - -export default function Home() { - return ( - <> - - - - - - - - - -
- -
-
- - ); -} diff --git a/src/pages/index.module.css b/src/pages/index.module.css deleted file mode 100644 index 0418ed2a2..000000000 --- a/src/pages/index.module.css +++ /dev/null @@ -1,15 +0,0 @@ -/** - * CSS styles for homepage - */ - -.features { - display: flex; - align-items: center; - padding: 2rem 0; - width: 100%; -} - -.featureSvg { - height: 200px; - width: 200px; -} diff --git a/src/sidebars/hub.js b/src/sidebars/hub.js new file mode 100644 index 000000000..6066b3983 --- /dev/null +++ b/src/sidebars/hub.js @@ -0,0 +1,101 @@ +module.exports = { + sidebar: [ + { + type: "doc", + id: "overview/index", + label: "Overview", + }, + { + type: "category", + label: "Concepts", + items: ["concepts/architecture"], + }, + { + type: "category", + label: "Products", + items: [ + { + type: "category", + label: "Insights", + link: { type: "doc", id: "products/insights/overview" }, + items: [ + { + type: "category", + link: { type: "doc", id: "products/insights/resource-exploration/overview" }, + label: "Resource Exploration", + items: [ + "products/insights/resource-exploration/query", + "products/insights/resource-exploration/filtering-resources", + ], + }, + { + type: "category", + label: "Lenses", + link: { type: "doc", id: "products/insights/lenses/overview" }, + items: ["products/insights/lenses/console"], + }, + "products/insights/packages", + "products/insights/definitions", + { + type: "category", + label: "Catalog", + link: { type: "doc", id: "products/insights/catalog/overview" }, + customProps: { badge: "Preview" }, + items: [ + "products/insights/catalog/console", + "products/insights/catalog/external-registry", + ], + }, + { + type: "doc", + id: "products/insights/metrics/overview", + label: "Metrics", + customProps: { badge: "Preview" }, + }, + ], + }, + ], + }, + { + type: "category", + label: "Deploy", + items: [ + "howtos/prerequisites", + "howtos/oidc-configuration", + { + type: "category", + label: "Databases", + link: { type: "doc", id: "howtos/databases/overview" }, + items: ["howtos/databases/aws-rds"], + }, + "howtos/install", + "howtos/connect-control-plane", + "howtos/connect-space", + "howtos/configure-kubectl", + ], + }, + { + type: "category", + label: "Production", + link: { type: "doc", id: "howtos/production-overview" }, + items: [ + "howtos/sizing", + "howtos/high-availability", + "howtos/autoscaling", + "howtos/rbac", + "howtos/upgrades", + "howtos/observability", + ], + }, + { + type: "category", + label: "Reference", + link: { type: "doc", id: "reference/index" }, + items: [ + "reference/feature-flags", + "reference/feature-releases", + "reference/helm-values", + ], + }, + ], +}; diff --git a/src/sidebars/main.js b/src/sidebars/main.js index 6bb9f2130..7efbb11a5 100644 --- a/src/sidebars/main.js +++ b/src/sidebars/main.js @@ -37,6 +37,11 @@ module.exports = { label: "Self-Hosted Spaces", href: "/self-hosted-spaces/overview", }, + { + type: "link", + label: "Hub", + href: "/hub/", + }, { type: "category", label: "CLI", diff --git a/src/theme/DocItem/Content/index.js b/src/theme/DocItem/Content/index.js new file mode 100644 index 000000000..4c3208550 --- /dev/null +++ b/src/theme/DocItem/Content/index.js @@ -0,0 +1,47 @@ +import React, { useRef } from 'react'; +import clsx from 'clsx'; +import { useLocation } from '@docusaurus/router'; +import { ThemeClassNames } from '@docusaurus/theme-common'; +import { useDoc } from '@docusaurus/plugin-content-docs/client'; +import Heading from '@theme/Heading'; +import MDXContent from '@theme/MDXContent'; +import CopyPageMenu from '@site/src/components/CopyPageMenu'; +import styles from './styles.module.css'; + +// Card-grid index pages have little real content to copy, so skip the menu there. +const EXCLUDED_PATHS = ['/guides/', '/manuals/', '/reference/']; + +function useSyntheticTitle() { + const { metadata, frontMatter, contentTitle } = useDoc(); + const shouldRender = !frontMatter.hide_title && typeof contentTitle === 'undefined'; + if (!shouldRender) return null; + return metadata.title; +} + +export default function DocItemContent({ children }) { + const syntheticTitle = useSyntheticTitle(); + const { metadata } = useDoc(); + const location = useLocation(); + const contentRef = useRef(); + + const isHidden = EXCLUDED_PATHS.includes(location.pathname); + const menu = !isHidden && ( + contentRef.current?.innerText} /> + ); + + return ( +
+ {syntheticTitle ? ( +
+ {syntheticTitle} + {menu} +
+ ) : ( + menu &&
{menu}
+ )} +
+ {children} +
+
+ ); +} diff --git a/src/theme/DocItem/Content/styles.module.css b/src/theme/DocItem/Content/styles.module.css new file mode 100644 index 000000000..78c0a3bf6 --- /dev/null +++ b/src/theme/DocItem/Content/styles.module.css @@ -0,0 +1,17 @@ +.docHeader { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.docHeader h1 { + margin-bottom: 0; +} + +.docHeaderStandalone { + display: flex; + justify-content: flex-end; + margin-bottom: 1rem; +} diff --git a/src/theme/DocSidebar/Desktop/Content/index.js b/src/theme/DocSidebar/Desktop/Content/index.js index 7a2b2eabc..3ac65f6b5 100644 --- a/src/theme/DocSidebar/Desktop/Content/index.js +++ b/src/theme/DocSidebar/Desktop/Content/index.js @@ -10,45 +10,68 @@ import { } from '@upbound/elements'; import styles from './styles.module.css'; -const versionsJson = require('../../../../../self-hosted-spaces_versions.json'); - const LATEST = 'latest'; -// Must match `versions.current.label` for the self-hosted-spaces plugin in docusaurus.config.js. -const LATEST_VERSION = '1.17'; -const versions = [ - { label: `${LATEST_VERSION} (Latest)`, value: LATEST }, - ...versionsJson.map((version) => ({ label: version, value: version })), +// One entry per versioned docs plugin. `latestLabel` must match the plugin's +// `versions.current.label` in docusaurus.config.js, and `versions` is its +// `_versions.json` file listing the archived versions. +const VERSIONED_DOCS = [ + { + basePath: 'self-hosted-spaces', + latestLabel: '1.17', + versions: require('../../../../../self-hosted-spaces_versions.json'), + }, + { + basePath: 'hub', + latestLabel: '1.0', + versions: require('../../../../../hub_versions.json'), + }, ]; +function getDocsConfig(pathname) { + const base = pathname.split('/').filter(Boolean)[0]; + return VERSIONED_DOCS.find((docs) => docs.basePath === base); +} + +function getVersionOptions(config) { + return [ + { label: `${config.latestLabel} (Latest)`, value: LATEST }, + ...config.versions.map((version) => ({ label: version, value: version })), + ]; +} + function getVersionFromPath(pathname) { const segments = pathname.split('/').filter(Boolean); - if (segments[0] === 'self-hosted-spaces' && /^\d+\.\d+$/.test(segments[1])) { + if (/^\d+\.\d+$/.test(segments[1])) { return segments[1]; } return LATEST; } -function buildVersionPath(pathname, selectedVersion) { +function buildVersionPath(pathname, basePath, selectedVersion) { const segments = pathname.split('/').filter(Boolean); const hasVersion = /^\d+\.\d+$/.test(segments[1]); const contentPath = '/' + segments.slice(hasVersion ? 2 : 1).join('/'); return selectedVersion === LATEST - ? `/self-hosted-spaces${contentPath}` - : `/self-hosted-spaces/${selectedVersion}${contentPath}`; + ? `/${basePath}${contentPath}` + : `/${basePath}/${selectedVersion}${contentPath}`; } export default function DocSidebarDesktopContentWrapper(props) { const location = useLocation(); - const isSelfHostedSpaces = location.pathname.startsWith('/self-hosted-spaces'); + const docsConfig = getDocsConfig(location.pathname); function handleVersionChange(value) { - window.location.href = buildVersionPath(location.pathname, value); + window.location.href = buildVersionPath( + location.pathname, + docsConfig.basePath, + value, + ); } return ( <> - {isSelfHostedSpaces && ( + {docsConfig && (