From 5b971d041b213e357991334d0df804b57f60dc74 Mon Sep 17 00:00:00 2001 From: Simon Murray Date: Thu, 6 Jun 2024 13:33:06 +0100 Subject: [PATCH] Import Code from Unikorn (#1) Pull in enough to make it build and pass CI checks. --- .github/workflows/pull-request.yaml | 64 ++ .github/workflows/release.yaml | 56 ++ Makefile | 205 +++++++ charts/region/Chart.yaml | 10 + .../region.unikorn-cloud.org_regions.yaml | 219 +++++++ charts/region/templates/_helpers.tpl | 128 ++++ .../region/templates/image-pull-secret.yaml | 11 + .../region-controller/clusterrole.yaml | 56 ++ .../region-controller/clusterrolebinding.yaml | 14 + .../region-controller/deployment.yaml | 58 ++ .../templates/region-controller/ingress.yaml | 39 ++ .../templates/region-controller/issuer.yaml | 10 + .../templates/region-controller/service.yaml | 17 + .../region-controller/serviceaccount.yaml | 10 + charts/region/templates/region.yaml | 58 ++ charts/region/values.yaml | 105 ++++ cmd/unikorn-region-controller/main.go | 113 ++++ .../unikorn-region-controller/.dockerignore | 3 + docker/unikorn-region-controller/Dockerfile | 9 + generated | 0 go.mod | 86 +++ go.sum | 286 +++++++++ hack/boilerplate.go.txt | 15 + hack/validate_openapi/main.go | 131 +++++ pkg/apis/unikorn/v1alpha1/doc.go | 20 + pkg/apis/unikorn/v1alpha1/register.go | 57 ++ pkg/apis/unikorn/v1alpha1/types.go | 132 +++++ .../unikorn/v1alpha1/zz_generated.deepcopy.go | 278 +++++++++ pkg/constants/constants.go | 45 ++ pkg/handler/error.go | 39 ++ pkg/handler/handler.go | 146 +++++ pkg/handler/options.go | 36 ++ pkg/handler/region/region.go | 138 +++++ pkg/openapi/client.go | 519 ++++++++++++++++ pkg/openapi/config.yaml | 3 + pkg/openapi/router.go | 253 ++++++++ pkg/openapi/schema.go | 148 +++++ pkg/openapi/server.spec.yaml | 223 +++++++ pkg/openapi/types.go | 94 +++ pkg/providers/constants.go | 38 ++ pkg/providers/helpers.go | 53 ++ pkg/providers/interfaces.go | 31 + pkg/providers/openstack/README.md | 87 +++ pkg/providers/openstack/blockstorage.go | 86 +++ pkg/providers/openstack/client.go | 207 +++++++ pkg/providers/openstack/compute.go | 291 +++++++++ pkg/providers/openstack/errors.go | 28 + pkg/providers/openstack/identity.go | 348 +++++++++++ pkg/providers/openstack/image.go | 207 +++++++ pkg/providers/openstack/network.go | 104 ++++ pkg/providers/openstack/provider.go | 556 ++++++++++++++++++ pkg/providers/types.go | 70 +++ pkg/server/options.go | 64 ++ pkg/server/server.go | 170 ++++++ pkg/server/util/json.go | 62 ++ pkg/server/util/octet_stream.go | 37 ++ 56 files changed, 6273 insertions(+) create mode 100644 .github/workflows/pull-request.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 Makefile create mode 100644 charts/region/Chart.yaml create mode 100644 charts/region/crds/region.unikorn-cloud.org_regions.yaml create mode 100644 charts/region/templates/_helpers.tpl create mode 100644 charts/region/templates/image-pull-secret.yaml create mode 100644 charts/region/templates/region-controller/clusterrole.yaml create mode 100644 charts/region/templates/region-controller/clusterrolebinding.yaml create mode 100644 charts/region/templates/region-controller/deployment.yaml create mode 100644 charts/region/templates/region-controller/ingress.yaml create mode 100644 charts/region/templates/region-controller/issuer.yaml create mode 100644 charts/region/templates/region-controller/service.yaml create mode 100644 charts/region/templates/region-controller/serviceaccount.yaml create mode 100644 charts/region/templates/region.yaml create mode 100644 charts/region/values.yaml create mode 100644 cmd/unikorn-region-controller/main.go create mode 100644 docker/unikorn-region-controller/.dockerignore create mode 100644 docker/unikorn-region-controller/Dockerfile create mode 100644 generated create mode 100644 go.mod create mode 100644 go.sum create mode 100644 hack/boilerplate.go.txt create mode 100644 hack/validate_openapi/main.go create mode 100644 pkg/apis/unikorn/v1alpha1/doc.go create mode 100644 pkg/apis/unikorn/v1alpha1/register.go create mode 100644 pkg/apis/unikorn/v1alpha1/types.go create mode 100644 pkg/apis/unikorn/v1alpha1/zz_generated.deepcopy.go create mode 100644 pkg/constants/constants.go create mode 100644 pkg/handler/error.go create mode 100644 pkg/handler/handler.go create mode 100644 pkg/handler/options.go create mode 100644 pkg/handler/region/region.go create mode 100644 pkg/openapi/client.go create mode 100644 pkg/openapi/config.yaml create mode 100644 pkg/openapi/router.go create mode 100644 pkg/openapi/schema.go create mode 100644 pkg/openapi/server.spec.yaml create mode 100644 pkg/openapi/types.go create mode 100644 pkg/providers/constants.go create mode 100644 pkg/providers/helpers.go create mode 100644 pkg/providers/interfaces.go create mode 100644 pkg/providers/openstack/README.md create mode 100644 pkg/providers/openstack/blockstorage.go create mode 100644 pkg/providers/openstack/client.go create mode 100644 pkg/providers/openstack/compute.go create mode 100644 pkg/providers/openstack/errors.go create mode 100644 pkg/providers/openstack/identity.go create mode 100644 pkg/providers/openstack/image.go create mode 100644 pkg/providers/openstack/network.go create mode 100644 pkg/providers/openstack/provider.go create mode 100644 pkg/providers/types.go create mode 100644 pkg/server/options.go create mode 100644 pkg/server/server.go create mode 100644 pkg/server/util/json.go create mode 100644 pkg/server/util/octet_stream.go diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml new file mode 100644 index 0000000..e564744 --- /dev/null +++ b/.github/workflows/pull-request.yaml @@ -0,0 +1,64 @@ +name: Unikorn Push +on: + pull_request: + types: + - opened + - synchronize + - reopened + - ready_for_review +env: + GO_VERSION: 1.22.1 +jobs: + Static: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup Go + uses: actions/setup-go@v3 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - name: Touch + run: make touch + - name: License Checker + run: make license + - name: Validate OpenAPI Schema + run: make validate + #- name: Validate documentation + # run: sudo apt -y install wbritish && make validate-docs + Runtime: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup Go + uses: actions/setup-go@v3 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - name: Install Helm + uses: azure/setup-helm@v3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Touch + run: make touch + - name: Golang CI/Helm Lint + run: make lint + - name: Build Images + run: make charts/region/crds images + - name: Build Generated Code + run: make generate + - name: Generated Code Checked In + run: '[[ -z $(git status --porcelain) ]]' + - name: Unit Test + run: make test-unit + - name: Archive code coverage results + uses: actions/upload-artifact@v3 + with: + name: code-coverage + path: cover.html + - name: Run Codecov + uses: codecov/codecov-action@v3 + env: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..c1d8a3a --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,56 @@ +name: Release +on: + push: + branches-ignore: + - '*' + tags: + - '*' +env: + GO_VERSION: 1.22.1 + REGISTRY: ghcr.io +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v3 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - name: Install Helm + uses: azure/setup-helm@v3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Docker Login + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and Push Images + run: make touch all images -e RELEASE=1 VERSION=${{ github.ref_name }} +# - name: Build SBOMS +# run: go run ./hack/sbom +# - name: Build Documentation +# run: sudo apt -y install wbritish && go run github.com/unikorn-cloud/core/hack/docs -o docs/server-api.md + - name: Configure Git + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + - name: Release Helm Chart + uses: unikorn-cloud/chart-release-action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Release + uses: softprops/action-gh-release@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + name: Release ${{ github.ref_name }} + tag_name: ${{ github.ref_name }} +# files: | +# sboms/*.spdx +# docs/server-api.md diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..66296f9 --- /dev/null +++ b/Makefile @@ -0,0 +1,205 @@ +# Application version encoded in all the binaries. +VERSION = 0.0.0 + +# Base go module name. +MODULE := $(shell cat go.mod | grep -m1 module | awk '{print $$2}') + +# Git revision. +REVISION := $(shell git rev-parse HEAD) + +# Commands to build, the first lot are architecture agnostic and will be built +# for your host's architecture. The latter are going to run in Kubernetes, so +# want to be amd64. +CONTROLLERS = \ + unikorn-region-controller + +# Release will do cross compliation of all images for the 'all' target. +# Note we aren't fucking about with docker here because that opens up a +# whole can of worms to do with caching modules and pisses on performance, +# primarily making me rage. For image creation, this, by necessity, +# REQUIRES multiarch images to be pushed to a remote registry because +# Docker apparently cannot support this after some 3 years... So don't +# run that target locally when compiling in release mode. +ifdef RELEASE +CONTROLLER_ARCH := amd64 arm64 +BUILDX_OUTPUT := --push +else +CONTROLLER_ARCH := $(shell go env GOARCH) +BUILDX_OUTPUT := --load +endif + +# Calculate the platform list to pass to docker buildx. +BUILDX_PLATFORMS := $(shell echo $(patsubst %,linux/%,$(CONTROLLER_ARCH)) | sed 's/ /,/g') + +# Some constants to describe the repository. +BINDIR = bin +CMDDIR = cmd +SRCDIR = src +GENDIR = generated +CRDDIR = charts/region/crds + +# Where to install things. +PREFIX = $(HOME)/bin + +# List of binaries to build. +CONTROLLER_BINARIES := $(foreach arch,$(CONTROLLER_ARCH),$(foreach ctrl,$(CONTROLLERS),$(BINDIR)/$(arch)-linux-gnu/$(ctrl))) + +# List of sources to trigger a build. +# TODO: Bazel may be quicker, but it's a massive hog, and a pain in the arse. +SOURCES := $(shell find . -type f -name *.go) go.mod go.sum + +# Source files defining custom resource APIs +APISRC = $(shell find pkg/apis -name [^z]*.go -type f) + +# Some bits about go. +GOPATH := $(shell go env GOPATH) +GOBIN := $(if $(shell go env GOBIN),$(shell go env GOBIN),$(GOPATH)/bin) + +# Common linker flags. +FLAGS=-trimpath -ldflags '-X $(MODULE)/pkg/constants.Version=$(VERSION) -X $(MODULE)/pkg/constants.Revision=$(REVISION)' + +# Defines the linter version. +LINT_VERSION=v1.57.1 + +# Defines the version of the CRD generation tools to use. +CONTROLLER_TOOLS_VERSION=v0.14.0 + +# Defines the version of code generator tools to use. +# This should be kept in sync with the Kubenetes library versions defined in go.mod. +CODEGEN_VERSION=v0.27.3 + +OPENAPI_CODEGEN_VERSION=v1.16.2 +OPENAPI_CODEGEN_FLAGS=-package openapi -config pkg/openapi/config.yaml +OPENAPI_SCHEMA=pkg/openapi/server.spec.yaml +OPENAPI_FILES = \ + pkg/openapi/types.go \ + pkg/openapi/schema.go \ + pkg/openapi/client.go \ + pkg/openapi/router.go + +MOCKGEN_VERSION=v0.3.0 + +# This is the base directory to generate kubernetes API primitives from e.g. +# clients and CRDs. +GENAPIBASE = github.com/unikorn-cloud/region/pkg/apis + +# This is the list of APIs to generate clients for. +GENAPIS = $(GENAPIBASE)/unikorn/v1alpha1 + +# These are generic arguments that need to be passed to client generation. +GENARGS = --go-header-file hack/boilerplate.go.txt --output-base ../../.. + +# This defines how docker containers are tagged. +DOCKER_ORG = ghcr.io/unikorn-cloud + +# Main target, builds all binaries. +.PHONY: all +all: $(CONTROLLER_BINARIES) $(CRDDIR) + +# Create a binary output directory, this should be an order-only prerequisite. +$(BINDIR) $(BINDIR)/amd64-linux-gnu $(BINDIR)/arm64-linux-gnu: + mkdir -p $@ + +# Create a binary from a command. +$(BINDIR)/%: $(SOURCES) $(GENDIR) $(OPENAPI_FILES) | $(BINDIR) + CGO_ENABLED=0 go build $(FLAGS) -o $@ $(CMDDIR)/$*/main.go + +$(BINDIR)/amd64-linux-gnu/%: $(SOURCES) $(GENDIR) $(OPENAPI_FILES) | $(BINDIR)/amd64-linux-gnu + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(FLAGS) -o $@ $(CMDDIR)/$*/main.go + +$(BINDIR)/arm64-linux-gnu/%: $(SOURCES) $(GENDIR) | $(BINDIR)/arm64-linux-gnu + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(FLAGS) -o $@ $(CMDDIR)/$*/main.go + +# TODO: we may wamt to consider porting the rest of the CRD and client generation +# stuff over... that said, we don't need the clients really do we, controller-runtime +# does all the magic for us. +.PHONY: generate +generate: + @go install go.uber.org/mock/mockgen@$(MOCKGEN_VERSION) + go generate ./... + +# Create container images. Use buildkit here, as it's the future, and it does +# good things, like per file .dockerignores and all that jazz. +.PHONY: images +images: $(CONTROLLER_BINARIES) + if [ -n "$(RELEASE)" ]; then docker buildx create --name unikorn --use; fi + for image in ${CONTROLLERS}; do docker buildx build --platform $(BUILDX_PLATFORMS) $(BUILDX_OUTPUT) -f docker/$${image}/Dockerfile -t ${DOCKER_ORG}/$${image}:${VERSION} .; done; + if [ -n "$(RELEASE)" ]; then docker buildx rm unikorn; fi + +# Purely lazy command that builds and pushes to docker hub. +.PHONY: images-push +images-push: images + for image in ${CONTROLLERS}; do docker push ${DOCKER_ORG}/$${image}:${VERSION}; done + +.PHONY: images-kind-load +images-kind-load: images + for image in ${CONTROLLERS}; do kind load docker-image ${DOCKER_ORG}/$${image}:${VERSION}; done + +.PHONY: test-unit +test-unit: + go test -coverpkg ./... -coverprofile cover.out ./... + go tool cover -html cover.out -o cover.html + +# Build a binary and install it. +$(PREFIX)/%: $(BINDIR)/% + install -m 750 $< $@ + +# Create any CRDs defined into the target directory. +$(CRDDIR): $(APISRC) + @mkdir -p $@ + @go install sigs.k8s.io/controller-tools/cmd/controller-gen@$(CONTROLLER_TOOLS_VERSION) + $(GOBIN)/controller-gen crd:crdVersions=v1 paths=./pkg/apis/unikorn/... output:dir=$@ + @touch $(CRDDIR) + +# Generate a clientset to interact with our custom resources. +$(GENDIR): $(APISRC) + @go install k8s.io/code-generator/cmd/deepcopy-gen@$(CODEGEN_VERSION) + $(GOBIN)/deepcopy-gen --input-dirs $(GENAPIS) -O zz_generated.deepcopy --bounding-dirs $(GENAPIBASE) $(GENARGS) + @touch $@ + +# Generate the server schema, types and router boilerplate. +pkg/openapi/types.go: $(OPENAPI_SCHEMA) + @go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@$(OPENAPI_CODEGEN_VERSION) + oapi-codegen -generate types $(OPENAPI_CODEGEN_FLAGS) -o $@ $< + +pkg/openapi/schema.go: $(OPENAPI_SCHEMA) + @go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@$(OPENAPI_CODEGEN_VERSION) + oapi-codegen -generate spec $(OPENAPI_CODEGEN_FLAGS) -o $@ $< + +pkg/openapi/client.go: $(OPENAPI_SCHEMA) + @go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@$(OPENAPI_CODEGEN_VERSION) + oapi-codegen -generate client $(OPENAPI_CODEGEN_FLAGS) -o $@ $< + +pkg/openapi/router.go: $(OPENAPI_SCHEMA) + @go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@$(OPENAPI_CODEGEN_VERSION) + oapi-codegen -generate chi-server $(OPENAPI_CODEGEN_FLAGS) -o $@ $< + +# When checking out, the files timestamps are pretty much random, and make cause +# spurious rebuilds of generated content. Call this to prevent that. +.PHONY: touch +touch: + touch $(CRDDIR) $(GENDIR) pkg/apis/unikorn/v1alpha1/zz_generated.deepcopy.go + +# Perform linting. +# This must pass or you will be denied by CI. +.PHOMY: lint +lint: $(GENDIR) + @go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(LINT_VERSION) + $(GOBIN)/golangci-lint run ./... + helm lint --strict charts/region + +# Validate the server OpenAPI schema is legit. +.PHONY: validate +validate: $(OPENAPI_FILES) + go run ./hack/validate_openapi + +# Validate the docs can be generated without fail. +.PHONY: validate-docs +validate-docs: $(OPENAPI_FILES) + go run github.com/unikorn-cloud/core/hack/docs --dry-run + +# Perform license checking. +# This must pass or you will be denied by CI. +.PHONY: license +license: + go run github.com/unikorn-cloud/core/hack/check_license diff --git a/charts/region/Chart.yaml b/charts/region/Chart.yaml new file mode 100644 index 0000000..497470d --- /dev/null +++ b/charts/region/Chart.yaml @@ -0,0 +1,10 @@ +apiVersion: v2 +name: region +description: A Helm chart for deploying Unikorn's Region Controller + +type: application + +version: v0.1.0 +appVersion: v0.1.0 + +icon: https://raw.githubusercontent.com/unikorn-cloud/unikorn/main/icons/default.png diff --git a/charts/region/crds/region.unikorn-cloud.org_regions.yaml b/charts/region/crds/region.unikorn-cloud.org_regions.yaml new file mode 100644 index 0000000..3424ce3 --- /dev/null +++ b/charts/region/crds/region.unikorn-cloud.org_regions.yaml @@ -0,0 +1,219 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: regions.region.unikorn-cloud.org +spec: + group: region.unikorn-cloud.org + names: + categories: + - unikorn + kind: Region + listKind: RegionList + plural: regions + singular: region + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .metadata.labels['unikorn-cloud\.org/name'] + name: display name + type: string + - jsonPath: .spec.provider + name: provider + type: string + - jsonPath: .status.conditions[?(@.type=="Available")].reason + name: status + type: string + - jsonPath: .metadata.creationTimestamp + name: age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + Region defines a geographical region where clusters can be provisioned. + A region defines the endpoints that can be used to derive information + about the provider for that region. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: RegionSpec defines metadata about the region. + properties: + openstack: + description: Openstack is provider specific configuration for the + region. + properties: + compute: + description: Compute is configuration for the compute service. + properties: + flavorExtraSpecsExclude: + description: |- + FlavorExtraSpecsExclude discards any flavors with the listed + extra specs keys. + items: + type: string + type: array + gpuDescriptors: + description: |- + GPUDescriptors defines a set of keys that can be probed to + list GPU topology information. + items: + properties: + expression: + description: |- + Expression describes how to extract the number of GPUs from the property + if it exists. This must contain exactly one submatch that is a number + e.g. "^(\d+)$". + type: string + property: + description: Property is the property name to examine + e.g. "resources.VGPU". + type: string + required: + - expression + - property + type: object + type: array + serverGroupPolicy: + description: |- + ServerGroupPolicy defines the anti-affinity policy to use for + scheduling cluster nodes. Defaults to "soft-anti-affinity". + type: string + type: object + endpoint: + description: Endpoint is the Keystone URL e.g. https://foo.bar:5000. + type: string + identity: + description: Identity is configuration for the identity service. + properties: + clusterRoles: + description: |- + ClusterRoles are the roles required to be assigned to an application + credential in order to provision, scale and deprovision a cluster, along + with any required for CNI/CSI functionality. + items: + type: string + type: array + type: object + image: + description: Image is configuration for the image service. + properties: + propertiesInclude: + description: |- + PropertiesInclude defines the set of properties that must all exist + for an image to be advertised by the provider. + items: + type: string + type: array + signingKey: + description: |- + SigningKey defines a PEM encoded public ECDSA signing key used to verify + the image is trusted. If specified, an image must contain the "digest" + property, the value of which must be a base64 encoded ECDSA signature of + the SHA256 hash of the image ID. + format: byte + type: string + type: object + serviceAccountSecret: + description: |- + ServiceAccountSecretName points to the secret containing credentials + required to perform the tasks the provider needs to perform. + properties: + name: + description: Name is the name of the object. + type: string + namespace: + description: Namespace is the namespace in which the object + resides. + type: string + required: + - name + - namespace + type: object + required: + - endpoint + - serviceAccountSecret + type: object + provider: + description: Type defines the provider type. + enum: + - openstack + type: string + required: + - provider + type: object + status: + description: RegionStatus defines the status of the region. + properties: + conditions: + description: Current service state of a region. + items: + description: |- + Condition is a generic condition type for use across all resource types. + It's generic so that the underlying controller-manager functionality can + be shared across all resources. + properties: + lastTransitionTime: + description: Last time the condition transitioned from one status + to another. + format: date-time + type: string + message: + description: Human-readable message indicating details about + last transition. + type: string + reason: + description: Unique, one-word, CamelCase reason for the condition's + last transition. + enum: + - Provisioning + - Provisioned + - Cancelled + - Errored + - Deprovisioning + - Deprovisioned + type: string + status: + description: |- + Status is the status of the condition. + Can be True, False, Unknown. + type: string + type: + description: Type is the type of the condition. + enum: + - Available + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/charts/region/templates/_helpers.tpl b/charts/region/templates/_helpers.tpl new file mode 100644 index 0000000..0fee57c --- /dev/null +++ b/charts/region/templates/_helpers.tpl @@ -0,0 +1,128 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "unikorn.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "unikorn.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "unikorn.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "unikorn.labels" -}} +helm.sh/chart: {{ include "unikorn.chart" . }} +{{ include "unikorn.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "unikorn.selectorLabels" -}} +app.kubernetes.io/name: {{ include "unikorn.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "unikorn.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "unikorn.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Create the container images +*/}} +{{- define "unikorn.defaultRepositoryPath" -}} +{{- if .Values.repository }} +{{- printf "%s/%s" .Values.repository .Values.organization }} +{{- else }} +{{- .Values.organization }} +{{- end }} +{{- end }} + +{{- define "unikorn.clusterManagerControllerImage" -}} +{{- .Values.clusterManagerController.image | default (printf "%s/unikorn-cluster-manager-controller:%s" (include "unikorn.defaultRepositoryPath" .) (.Values.tag | default .Chart.Version)) }} +{{- end }} + +{{- define "unikorn.clusterControllerImage" -}} +{{- .Values.clusterController.image | default (printf "%s/unikorn-cluster-controller:%s" (include "unikorn.defaultRepositoryPath" .) (.Values.tag | default .Chart.Version)) }} +{{- end }} + +{{- define "unikorn.monitorImage" -}} +{{- .Values.monitor.image | default (printf "%s/unikorn-monitor:%s" (include "unikorn.defaultRepositoryPath" .) (.Values.tag | default .Chart.Version)) }} +{{- end }} + +{{- define "unikorn.regionImage" -}} +{{- .Values.region.image | default (printf "%s/unikorn-region:%s" (include "unikorn.defaultRepositoryPath" .) (.Values.tag | default .Chart.Version)) }} +{{- end }} + +{{/* +Create Prometheus labels +*/}} +{{- define "unikorn.prometheusServiceSelector" -}} +prometheus.unikorn-cloud.org/app: unikorn +{{- end }} + +{{- define "unikorn.prometheusJobLabel" -}} +prometheus.unikorn-cloud.org/job +{{- end }} + +{{- define "unikorn.prometheusLabels" -}} +{{ include "unikorn.prometheusServiceSelector" . }} +{{ include "unikorn.prometheusJobLabel" . }}: {{ .job }} +{{- end }} + +{{/* +Create image pull secrets +*/}} +{{- define "unikorn.imagePullSecrets" -}} +{{- if .Values.imagePullSecret -}} +- name: {{ .Values.imagePullSecret }} +{{ end }} +{{- if .Values.dockerConfig -}} +- name: docker-config +{{- end }} +{{- end }} + +{{/* +Creates predicatable Kubernetes name compatible UUIDs from name. +Note we always start with a letter (kubernetes DNS label requirement), +group 3 starts with "4" (UUIDv4 aka "random") and group 4 with "8" +(the variant aka RFC9562). +*/}} +{{ define "resource.id" -}} +{{- $sum := sha256sum . -}} +{{ printf "f%s-%s-4%s-8%s-%s" (substr 1 8 $sum) (substr 8 12 $sum) (substr 13 16 $sum) (substr 17 20 $sum) (substr 20 32 $sum) }} +{{- end }} diff --git a/charts/region/templates/image-pull-secret.yaml b/charts/region/templates/image-pull-secret.yaml new file mode 100644 index 0000000..6d7d859 --- /dev/null +++ b/charts/region/templates/image-pull-secret.yaml @@ -0,0 +1,11 @@ +{{- if .Values.dockerConfig }} +apiVersion: v1 +kind: Secret +metadata: + name: docker-config + labels: + {{- include "unikorn.labels" . | nindent 4 }} +type: kubernetes.io/dockerconfigjson +data: + .dockerconfigjson: {{ .Values.dockerConfig }} +{{- end }} diff --git a/charts/region/templates/region-controller/clusterrole.yaml b/charts/region/templates/region-controller/clusterrole.yaml new file mode 100644 index 0000000..67312a0 --- /dev/null +++ b/charts/region/templates/region-controller/clusterrole.yaml @@ -0,0 +1,56 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: unikorn-region + labels: + {{- include "unikorn.labels" . | nindent 4 }} +rules: +# Orchestrate Unikorn resources (my job). +- apiGroups: + - unikorn-cloud.org + resources: + - clustermanagers + - kubernetesclusters + verbs: + - create + - get + - list + - watch + - patch + - delete +- apiGroups: + - unikorn-cloud.org + resources: + - regions + - clustermanagerapplicationbundles + - kubernetesclusterapplicationbundles + - helmapplications + verbs: + - list + - watch +# Find project namespaces +- apiGroups: + - "" + resources: + - namespaces + verbs: + - list + - watch +# Get secrets, ugh, for kubeconfigs. +- apiGroups: + - "" + resources: + - secrets + - services + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - events + verbs: + - get + - list + - watch diff --git a/charts/region/templates/region-controller/clusterrolebinding.yaml b/charts/region/templates/region-controller/clusterrolebinding.yaml new file mode 100644 index 0000000..23900ce --- /dev/null +++ b/charts/region/templates/region-controller/clusterrolebinding.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: unikorn-region + labels: + {{- include "unikorn.labels" . | nindent 4 }} +subjects: +- kind: ServiceAccount + namespace: {{ .Release.Namespace }} + name: unikorn-region +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: unikorn-region diff --git a/charts/region/templates/region-controller/deployment.yaml b/charts/region/templates/region-controller/deployment.yaml new file mode 100644 index 0000000..6a34a1a --- /dev/null +++ b/charts/region/templates/region-controller/deployment.yaml @@ -0,0 +1,58 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: unikorn-region + labels: + {{- include "unikorn.labels" . | nindent 4 }} +spec: + replicas: 1 + selector: + matchLabels: + app: unikorn-region + template: + metadata: + labels: + app: unikorn-region + spec: + containers: + - name: unikorn-region + image: {{ include "unikorn.regionImage" . }} + args: + {{- with $cors := .Values.region.cors }} + {{- range $origin := $cors.allowOrigin }} + {{ printf "- --cors-allow-origin=%s" $origin | nindent 8 }} + {{- end }} + {{- if $cors.maxAge }} + {{ printf "- --cors-max-age=%s" $cors.maxAge | nindent 8 }} + {{- end }} + {{- end }} + {{- with $oidc := .Values.region.oidc }} + - --oidc-issuer={{ $oidc.issuer }} + {{- if $oidc.issuerCA }} + {{ printf "- --oidc-issuer-ca=%s" $oidc.issuerCA | nindent 8 }} + {{- end }} + {{- end }} + {{- if .Values.region.otlpEndpoint }} + {{ printf "- --otlp-endpoint=%s" .Values.region.otlpEndpoint | nindent 8 }} + {{- end }} + ports: + - name: http + containerPort: 6080 + - name: prometheus + containerPort: 8080 + - name: pprof + containerPort: 6060 + # Note, this is quite CPU intensive, especially when going wide! + # TODO: profile me. + resources: + requests: + cpu: "1" + memory: 50Mi + limits: + cpu: "2" + memory: 100Mi + securityContext: + readOnlyRootFilesystem: true + serviceAccountName: unikorn-region + securityContext: + runAsNonRoot: true diff --git a/charts/region/templates/region-controller/ingress.yaml b/charts/region/templates/region-controller/ingress.yaml new file mode 100644 index 0000000..5ca770a --- /dev/null +++ b/charts/region/templates/region-controller/ingress.yaml @@ -0,0 +1,39 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: unikorn-region + labels: + {{- include "unikorn.labels" . | nindent 4 }} + annotations: + {{- if .Values.region.ingress.clusterIssuer }} + cert-manager.io/cluster-issuer: {{ .Values.region.ingress.clusterIssuer | indent 2 }} + {{- else if .Values.region.ingress.issuer }} + cert-manager.io/issuer: {{ .Values.region.ingress.issuer }} + {{- else }} + cert-manager.io/issuer: unikorn-region-ingress + {{- end }} + {{- if .Values.region.ingress.externalDns }} + external-dns.alpha.kubernetes.io/hostname: {{ .Values.region.ingress.host }} + {{- end }} +spec: + ingressClassName: {{ .Values.region.ingress.class }} + # For development you will want to add these names to /etc/hosts for the ingress + # endpoint address. + tls: + - hosts: + - {{ .Values.region.ingress.host }} + secretName: unikorn-region-ingress-tls + rules: + # The the UI is written as a JAMstack application, so the API is accessed via + # the same host to avoid CORS, and therefore uses routing to hit the correct + # service. + - host: {{ .Values.region.ingress.host }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: unikorn-region + port: + name: http diff --git a/charts/region/templates/region-controller/issuer.yaml b/charts/region/templates/region-controller/issuer.yaml new file mode 100644 index 0000000..a102f74 --- /dev/null +++ b/charts/region/templates/region-controller/issuer.yaml @@ -0,0 +1,10 @@ +{{- if (not .Values.region.ingress.annotations) }} +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: unikorn-region-ingress + labels: + {{- include "unikorn.labels" . | nindent 4 }} +spec: + selfSigned: {} +{{- end }} diff --git a/charts/region/templates/region-controller/service.yaml b/charts/region/templates/region-controller/service.yaml new file mode 100644 index 0000000..459e3a9 --- /dev/null +++ b/charts/region/templates/region-controller/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: unikorn-region + labels: + {{- include "unikorn.labels" . | nindent 4 }} + {{- include "unikorn.prometheusLabels" (dict "job" "unikorn-region") | nindent 4 }} +spec: + selector: + app: unikorn-region + ports: + - name: http + port: 80 + targetPort: http + - name: prometheus + port: 8080 + targetPort: prometheus diff --git a/charts/region/templates/region-controller/serviceaccount.yaml b/charts/region/templates/region-controller/serviceaccount.yaml new file mode 100644 index 0000000..9ade44e --- /dev/null +++ b/charts/region/templates/region-controller/serviceaccount.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: unikorn-region + labels: + {{- include "unikorn.labels" . | nindent 4 }} +{{- with ( include "unikorn.imagePullSecrets" . ) }} +imagePullSecrets: +{{ . }} +{{- end }} diff --git a/charts/region/templates/region.yaml b/charts/region/templates/region.yaml new file mode 100644 index 0000000..ff8cd9b --- /dev/null +++ b/charts/region/templates/region.yaml @@ -0,0 +1,58 @@ +{{- range $region := .Values.regions }} +apiVersion: unikorn-cloud.org/v1alpha1 +kind: Region +metadata: + name: {{ include "resource.id" $region.name }} + labels: + unikorn-cloud.org/name: {{ $region.name }} + {{- include "unikorn.labels" $ | nindent 4 }} +spec: + provider: {{ $region.provider }} + {{- with $openstack := $region.openstack }} + openstack: + endpoint: {{ $openstack.endpoint }} + serviceAccountSecret: + namespace: {{ $openstack.serviceAccountSecret.namespace }} + name: {{ $openstack.serviceAccountSecret.name }} + {{- with $identity := $openstack.identity }} + {{ printf "identity:" | nindent 4 }} + {{- with $roles := $identity.clusterRoles }} + {{ printf "clusterRoles:" | nindent 6 }} + {{- range $role := $roles }} + {{ printf "- %s" $role | nindent 6 }} + {{- end }} + {{- end }} + {{- end }} + {{- with $compute := $openstack.compute }} + {{ printf "compute:" | nindent 4 }} + {{- with $policy := $compute.regionGroupPolicy }} + {{ printf "regionGroupPolicy: %s" $policy | nindent 6 }} + {{- end }} + {{- with $specs := $compute.flavorExtraSpecsExclude }} + {{ printf "flavorExtraSpecsExclude:" | nindent 6 }} + {{- range $spec := $specs }} + {{ printf "- %s" $spec | nindent 6 }} + {{- end }} + {{- end }} + {{- with $descriptors := $compute.gpuDescriptors }} + {{ printf "gpuDescriptors:" | nindent 6 }} + {{- range $descriptor := $descriptors }} + {{ printf "- property: %s" $descriptor.property | nindent 6 }} + {{ printf " expression: %s" $descriptor.expression | nindent 6 }} + {{- end }} + {{- end }} + {{- end }} + {{- with $image := $openstack.image}} + {{ printf "image:" | nindent 4 }} + {{- with $properties := $image.propertiesInclude }} + {{ printf "propertiesInclude:" | nindent 6 }} + {{- range $property := $properties }} + {{ printf "- %s" $property | nindent 6 }} + {{- end }} + {{- end }} + {{- with $signingKey := $image.signingKey }} + {{ printf "signingKey: %s" $signingKey | nindent 6 }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/region/values.yaml b/charts/region/values.yaml new file mode 100644 index 0000000..c07bba1 --- /dev/null +++ b/charts/region/values.yaml @@ -0,0 +1,105 @@ +# Set the global container repo. +repository: ghcr.io + +# Set the global container repo organization. +organization: unikorn-cloud + +# Set the global container tag. +# Defaults to the chart revision, which should be in lock-step with the +# actual release. +# tag: + +# Set the docker configuration, doing so will create a secret and link it +# to the service accounts of all the controllers. You can do something like: +# --set dockerConfig=$(cat ~/.docker/config.json | base64 -w0) +dockerConfig: + +# Set the image pull secret on the service accounts of all the controllers. +# This is an alternative to dockerConfigs, but unlikely to play ball with +# ArgoCD as it's a foreign object that needs pruning. +imagePullSecret: + +# Region discovery information. +# regions: +# - # The name of the region, must be a unique DNS label. +# name: uk-manchester +# # Provider type, must be one of "openstack". +# provider: openstack +# # Openstack specific configuration. +# openstack: +# # Keystone endpoint. +# endpoint: https://keystone.my.cloud:5000 +# # Keystone credentials. +# serviceAccountSecret: +# namespace: default +# name: openstack-admin-secret +# # Identity service configuration. +# identity: +# # Roles to be assigned to application credentials that are used for +# # cluster provisioning and life-cycle management. +# clusterRoles: +# - member +# - load-balancer_member +# # Compute service configuration. +# compute: +# # Kubernetes control plane scheduling policy. +# serverGroupPolicy: soft-anti-affinity +# # Flavors containing any of the specified extra specs will be discarded. +# flavorExtraSpecsExclude: +# - resources:CUSTOM_BAREMETAL +# # Define properties on flavors and how to extract the number of GPUs from them. +# gpuDescriptors: +# - property: resources:PGPU +# expression: ^(\d+)$ +# - property: resources:VGPU +# expression: ^(\d+)$ +# # Image service configuration. +# image: +# # Images must contain all the following properties to be exposed. +# propertiesInclude: +# - k8s +# # If specified the image signing key defines a base64 PEM encoded ECDSA +# # public key used to trust images. Images must have the "digest" property +# # defined, and its value must be the ECDSA signature of the SHA256 hash of +# # the image ID. +# signingKey: ~ + +# REST server specific configuration. +region: + # Allows override of the global default image. + image: + + ingress: + # Sets the ingress class to use. + class: nginx + + # Sets the DNS hosts/X.509 Certs. + host: region.unikorn-cloud.org + + # Cert Manager certificate issuer to use. If not specified it will generate a + # self signed one. + issuer: ~ + + # clusterIssuer to use. + clusterIssuer: ~ + + # If true, will add the external DNS hostname annotation. + externalDns: false + + # Allows CORS to be configured/secured + # cors: + # # Broswers must send requests from these origin servers, defaults to * if not set. + # allowOrigin: ['*'] + # # How long to cache the CORS preflight for, mostly useless as browsers override this. + # maxAge: 86400 + + oidc: + # OIDC issuer used to discover OIDC configuration and verify access tokens. + issuer: https://identity.unikorn-cloud.org + + # CA certificate to use to verify connections to the issuer, used in development only. + # This is a base64 encoded PEM file. + # issuerCA: + + # Sets the OTLP endpoint for shipping spans. + # otlpEndpoint: jaeger-collector.default:4318 diff --git a/cmd/unikorn-region-controller/main.go b/cmd/unikorn-region-controller/main.go new file mode 100644 index 0000000..e37ccad --- /dev/null +++ b/cmd/unikorn-region-controller/main.go @@ -0,0 +1,113 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "errors" + "flag" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/spf13/pflag" + + "github.com/unikorn-cloud/core/pkg/client" + unikornv1 "github.com/unikorn-cloud/region/pkg/apis/unikorn/v1alpha1" + "github.com/unikorn-cloud/region/pkg/constants" + "github.com/unikorn-cloud/region/pkg/server" + + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// start is the entry point to server. +func start() { + s := &server.Server{} + s.AddFlags(flag.CommandLine, pflag.CommandLine) + + pflag.CommandLine.AddGoFlagSet(flag.CommandLine) + pflag.Parse() + + // Get logging going first, log sinks will expect JSON formatted output for everything. + s.SetupLogging() + + logger := log.Log.WithName(constants.Application) + + // Hello World! + logger.Info("service starting", "application", constants.Application, "version", constants.Version, "revision", constants.Revision) + + // Create a root context for things to hang off of. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := s.SetupOpenTelemetry(ctx); err != nil { + logger.Error(err, "failed to setup OpenTelemetry") + + return + } + + client, err := client.New(ctx, unikornv1.AddToScheme) + if err != nil { + logger.Error(err, "failed to create client") + + return + } + + server, err := s.GetServer(client) + if err != nil { + logger.Error(err, "failed to setup Handler") + + return + } + + // Register a signal handler to trigger a graceful shutdown. + stop := make(chan os.Signal, 1) + + signal.Notify(stop, syscall.SIGTERM) + + go func() { + <-stop + + // Cancel anything hanging off the root context. + cancel() + + // Shutdown the server, Kubernetes gives us 30 seconds before a SIGKILL. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + logger.Error(err, "server shutdown error") + } + }() + + if err := server.ListenAndServe(); err != nil { + if errors.Is(err, http.ErrServerClosed) { + return + } + + logger.Error(err, "unexpected server error") + + return + } +} + +func main() { + start() +} diff --git a/docker/unikorn-region-controller/.dockerignore b/docker/unikorn-region-controller/.dockerignore new file mode 100644 index 0000000..b6a7519 --- /dev/null +++ b/docker/unikorn-region-controller/.dockerignore @@ -0,0 +1,3 @@ +* +!bin/*-linux-gnu/unikorn-region-controller +!hack/passwd.nonroot diff --git a/docker/unikorn-region-controller/Dockerfile b/docker/unikorn-region-controller/Dockerfile new file mode 100644 index 0000000..a54e581 --- /dev/null +++ b/docker/unikorn-region-controller/Dockerfile @@ -0,0 +1,9 @@ +FROM gcr.io/distroless/static:nonroot + +# This is implcitly created by 'docker buildx build' +ARG TARGETARCH + +# Required as we are talking to Openstack public endpoints. +COPY bin/${TARGETARCH}-linux-gnu/unikorn-region-controller / + +ENTRYPOINT ["/unikorn-region-controller"] diff --git a/generated b/generated new file mode 100644 index 0000000..e69de29 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..df3a00f --- /dev/null +++ b/go.mod @@ -0,0 +1,86 @@ +module github.com/unikorn-cloud/region + +go 1.22.1 + +require ( + github.com/getkin/kin-openapi v0.123.0 + github.com/go-chi/chi/v5 v5.0.12 + github.com/google/uuid v1.6.0 + github.com/gophercloud/gophercloud/v2 v2.0.0-rc.3 + github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 + github.com/oapi-codegen/runtime v1.1.1 + github.com/spf13/pflag v1.0.5 + github.com/unikorn-cloud/core v0.1.43 + go.opentelemetry.io/otel v1.27.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 + go.opentelemetry.io/otel/sdk v1.27.0 + go.opentelemetry.io/otel/trace v1.27.0 + k8s.io/api v0.29.3 + k8s.io/apimachinery v0.29.3 + sigs.k8s.io/controller-runtime v0.17.2 + sigs.k8s.io/yaml v1.4.0 +) + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/coreos/go-oidc/v3 v3.10.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.0 // indirect + github.com/go-jose/go-jose/v3 v3.0.3 // indirect + github.com/go-jose/go-jose/v4 v4.0.1 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/gophercloud/gophercloud v1.3.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/imdario/mergo v0.3.16 // indirect + github.com/invopop/yaml v0.2.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pkg/errors v0.9.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 // indirect + go.opentelemetry.io/otel/metric v1.27.0 // indirect + go.opentelemetry.io/proto/otlp v1.2.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/oauth2 v0.20.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/term v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + golang.org/x/time v0.5.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240520151616-dc85e6b867a5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240515191416-fc5f0ca64291 // indirect + google.golang.org/grpc v1.64.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/client-go v0.29.3 // indirect + k8s.io/klog/v2 v2.120.1 // indirect + k8s.io/kube-openapi v0.0.0-20240322212309-b815d8309940 // indirect + k8s.io/utils v0.0.0-20240310230437-4693a0247e57 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bd37d5e --- /dev/null +++ b/go.sum @@ -0,0 +1,286 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-oidc/v3 v3.10.0 h1:tDnXHnLyiTVyT/2zLDGj09pFPkhND8Gl8lnTRhoEaJU= +github.com/coreos/go-oidc/v3 v3.10.0/go.mod h1:5j11xcw0D3+SGxn6Z/WFADsgcWVMyNAlSQupk0KK3ac= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.0 h1:y2DdzBAURM29NFF94q6RaY4vjIH1rtwDapwQtU84iWk= +github.com/emicklei/go-restful/v3 v3.12.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= +github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= +github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/getkin/kin-openapi v0.123.0 h1:zIik0mRwFNLyvtXK274Q6ut+dPh6nlxBp0x7mNrPhs8= +github.com/getkin/kin-openapi v0.123.0/go.mod h1:wb1aSZA/iWmorQP9KTAS/phLj/t17B5jT7+fS8ed9NM= +github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= +github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= +github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U= +github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gophercloud/gophercloud v1.3.0 h1:RUKyCMiZoQR3VlVR5E3K7PK1AC3/qppsWYo6dtBiqs8= +github.com/gophercloud/gophercloud v1.3.0/go.mod h1:aAVqcocTSXh2vYFZ1JTvx4EQmfgzxRcNupUfxZbBNDM= +github.com/gophercloud/gophercloud/v2 v2.0.0-rc.3 h1:Gc1oFIROarJoIcvg63BsXrK6G+DPwYVs5gKlmvV2UC8= +github.com/gophercloud/gophercloud/v2 v2.0.0-rc.3/go.mod h1:ZKbcGNjxFTSaP5wlvtLDdsppllD/UGGvXBPqcjeqA8Y= +github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 h1:sH7xkTfYzxIEgzq1tDHIMKRh1vThOEOGNsettdEeLbE= +github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56/go.mod h1:VSalo4adEk+3sNkmVJLnhHoOyOYYS8sTWLG4mv5BKto= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= +github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro= +github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= +github.com/onsi/ginkgo/v2 v2.14.0 h1:vSmGj2Z5YPb9JwCWT6z6ihcUvDhuXLc3sJiqd3jMKAY= +github.com/onsi/ginkgo/v2 v2.14.0/go.mod h1:JkUdW7JkN0V6rFvsHcJ478egV3XH9NxpD27Hal/PhZw= +github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8= +github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= +github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= +github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos= +github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8= +github.com/prometheus/common v0.51.1 h1:eIjN50Bwglz6a/c3hAgSMcofL3nD+nFQkV6Dd4DsQCw= +github.com/prometheus/common v0.51.1/go.mod h1:lrWtQx+iDfn2mbH5GUzlH9TSHyfZpHkSiG1W7y3sF2Q= +github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o= +github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/unikorn-cloud/core v0.1.43 h1:QszxVqWaZXIzSlf0qkHa5m8cRnjKGvHjM8iUk0Y3U9A= +github.com/unikorn-cloud/core v0.1.43/go.mod h1:cP39UQN7aSmsfjQuSMsworI4oBIwx4oA4u20CbPpfZw= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg= +go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 h1:R9DE4kQ4k+YtfLI2ULwX82VtNQ2J8yZmA7ZIF/D+7Mc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0/go.mod h1:OQFyQVrDlbe+R7xrEyDr/2Wr67Ol0hRUgsfA+V5A95s= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 h1:QY7/0NeRPKlzusf40ZE4t1VlMKbqSNT7cJRYzWuja0s= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0/go.mod h1:HVkSiDhTM9BoUJU8qE6j2eSWLLXvi1USXjyd2BXT8PY= +go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik= +go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak= +go.opentelemetry.io/otel/sdk v1.27.0 h1:mlk+/Y1gLPLn84U4tI8d3GNJmGT/eXe3ZuOXN9kTWmI= +go.opentelemetry.io/otel/sdk v1.27.0/go.mod h1:Ha9vbLwJE6W86YstIywK2xFfPjbWlCuwPtMkKdz/Y4A= +go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw= +go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4= +go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94= +go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= +golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo= +golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/genproto/googleapis/api v0.0.0-20240520151616-dc85e6b867a5 h1:P8OJ/WCl/Xo4E4zoe4/bifHpSmmKwARqyqE4nW6J2GQ= +google.golang.org/genproto/googleapis/api v0.0.0-20240520151616-dc85e6b867a5/go.mod h1:RGnPtTG7r4i8sPlNyDeikXF99hMM+hN6QMm4ooG9g2g= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240515191416-fc5f0ca64291 h1:AgADTJarZTBqgjiUzRgfaBchgYB3/WFTC80GPwsMcRI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240515191416-fc5f0ca64291/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= +google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.29.3 h1:2ORfZ7+bGC3YJqGpV0KSDDEVf8hdGQ6A03/50vj8pmw= +k8s.io/api v0.29.3/go.mod h1:y2yg2NTyHUUkIoTC+phinTnEa3KFM6RZ3szxt014a80= +k8s.io/apiextensions-apiserver v0.29.0 h1:0VuspFG7Hj+SxyF/Z/2T0uFbI5gb5LRgEyUVE3Q4lV0= +k8s.io/apiextensions-apiserver v0.29.0/go.mod h1:TKmpy3bTS0mr9pylH0nOt/QzQRrW7/h7yLdRForMZwc= +k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU= +k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU= +k8s.io/client-go v0.29.3 h1:R/zaZbEAxqComZ9FHeQwOh3Y1ZUs7FaHKZdQtIc2WZg= +k8s.io/client-go v0.29.3/go.mod h1:tkDisCvgPfiRpxGnOORfkljmS+UrW+WtXAy2fTvXJB0= +k8s.io/component-base v0.29.3 h1:Oq9/nddUxlnrCuuR2K/jp6aflVvc0uDvxMzAWxnGzAo= +k8s.io/component-base v0.29.3/go.mod h1:Yuj33XXjuOk2BAaHsIGHhCKZQAgYKhqIxIjIr2UXYio= +k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= +k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20240322212309-b815d8309940 h1:qVoMaQV5t62UUvHe16Q3eb2c5HPzLHYzsi0Tu/xLndo= +k8s.io/kube-openapi v0.0.0-20240322212309-b815d8309940/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= +k8s.io/utils v0.0.0-20240310230437-4693a0247e57 h1:gbqbevonBh57eILzModw6mrkbwM0gQBEuevE/AaBsHY= +k8s.io/utils v0.0.0-20240310230437-4693a0247e57/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.17.2 h1:FwHwD1CTUemg0pW2otk7/U5/i5m2ymzvOXdbeGOUvw0= +sigs.k8s.io/controller-runtime v0.17.2/go.mod h1:+MngTvIQQQhfXtwfdGw/UOQ/aIaqsYywfCINOtwMO/s= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt new file mode 100644 index 0000000..d0566f4 --- /dev/null +++ b/hack/boilerplate.go.txt @@ -0,0 +1,15 @@ +/* +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ diff --git a/hack/validate_openapi/main.go b/hack/validate_openapi/main.go new file mode 100644 index 0000000..dc1ba94 --- /dev/null +++ b/hack/validate_openapi/main.go @@ -0,0 +1,131 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "fmt" + "net/http" + "os" + + "github.com/unikorn-cloud/region/pkg/openapi" +) + +//nolint:gochecknoglobals +var failed bool + +func report(v ...interface{}) { + fmt.Println(v...) + + failed = true +} + +//nolint:gocognit,cyclop +func main() { + spec, err := openapi.GetSwagger() + if err != nil { + report("failed to load spec", err) + } + + if err := spec.Validate(context.Background()); err != nil { + report("failed to validate spec", err) + } + + for _, pathName := range spec.Paths.InMatchingOrder() { + path := spec.Paths.Find(pathName) + + for method, operation := range path.Operations() { + // Everything needs security defining. + _, noSecurityAllowed := operation.Extensions["x-no-security-requirements"] + + if operation.Security == nil && !noSecurityAllowed { + report("no security requirements set for ", method, pathName) + os.Exit(1) + } + + // If you have multiple, then the errors become ambiguous to handle. + if operation.Security != nil && len(*operation.Security) != 1 { + report("security requirement for", method, pathName, "require one security requirement") + os.Exit(1) + } + + //nolint:nestif + if method == http.MethodGet { + // Where there are responses, they must have a schema. + for code := 100; code < 600; code++ { + responseRef := operation.Responses.Status(code) + if responseRef == nil { + continue + } + + if code != http.StatusOK { + continue + } + + response := responseRef.Value + if response == nil { + response = spec.Components.Responses[responseRef.Ref].Value + } + + if response.Content == nil { + report("no content type set for", code, method, pathName, "response") + } + + for mimeType, mediaType := range response.Content { + if mimeType == "application/json" && mediaType.Schema == nil { + report("no schema set for", mimeType, code, method, pathName, "response") + } + } + } + } + + //nolint:nestif + if method == http.MethodPost || method == http.MethodPut { + // You have to explicitly opt out from following the rules. + _, noBodyAllowed := operation.Extensions["x-no-body"] + + // POST/PUT calls will have something to validate. + if operation.RequestBody == nil { + if noBodyAllowed { + continue + } + + report("no request body set for", method, pathName) + + continue + } + + body := operation.RequestBody.Value + if body == nil { + body = spec.Components.RequestBodies[operation.RequestBody.Ref].Value + } + + // Request bodies will have a schema. + for mimeType, mediaType := range body.Content { + if mediaType.Schema == nil { + report("no schema set for", mimeType, method, pathName) + } + } + } + } + } + + if failed { + os.Exit(1) + } +} diff --git a/pkg/apis/unikorn/v1alpha1/doc.go b/pkg/apis/unikorn/v1alpha1/doc.go new file mode 100644 index 0000000..1cdabb5 --- /dev/null +++ b/pkg/apis/unikorn/v1alpha1/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// +k8s:deepcopy-gen=package,register +// +groupName=region.unikorn-cloud.org +package v1alpha1 diff --git a/pkg/apis/unikorn/v1alpha1/register.go b/pkg/apis/unikorn/v1alpha1/register.go new file mode 100644 index 0000000..e86f0f5 --- /dev/null +++ b/pkg/apis/unikorn/v1alpha1/register.go @@ -0,0 +1,57 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +const ( + // GroupName is the Kubernetes API group our resources belong to. + GroupName = "region.unikorn-cloud.org" + // GroupVersion is the version of our custom resources. + GroupVersion = "v1alpha1" + // Group is group/version of our resources. + Group = GroupName + "/" + GroupVersion +) + +var ( + // SchemeGroupVersion defines the GV of our resources. + //nolint:gochecknoglobals + SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: GroupVersion} + + // SchemeBuilder creates a mapping between GVK and type. + //nolint:gochecknoglobals + SchemeBuilder = &scheme.Builder{GroupVersion: SchemeGroupVersion} + + // AddToScheme adds our GVK to resource mappings to an existing scheme. + //nolint:gochecknoglobals + AddToScheme = SchemeBuilder.AddToScheme +) + +//nolint:gochecknoinits +func init() { + SchemeBuilder.Register(&Region{}, &RegionList{}) +} + +// Resource maps a resource type to a group resource. +func Resource(resource string) schema.GroupResource { + return SchemeGroupVersion.WithResource(resource).GroupResource() +} diff --git a/pkg/apis/unikorn/v1alpha1/types.go b/pkg/apis/unikorn/v1alpha1/types.go new file mode 100644 index 0000000..8b2aa3b --- /dev/null +++ b/pkg/apis/unikorn/v1alpha1/types.go @@ -0,0 +1,132 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + unikornv1core "github.com/unikorn-cloud/core/pkg/apis/unikorn/v1alpha1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Provider is used to communicate the cloud type. +// +kubebuilder:validation:Enum=openstack +type Provider string + +const ( + ProviderOpenstack Provider = "openstack" +) + +// RegionList is a typed list of regions. +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type RegionList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Region `json:"items"` +} + +// Region defines a geographical region where clusters can be provisioned. +// A region defines the endpoints that can be used to derive information +// about the provider for that region. +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +// +kubebuilder:resource:scope=Cluster,categories=unikorn +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="display name",type="string",JSONPath=".metadata.labels['unikorn-cloud\\.org/name']" +// +kubebuilder:printcolumn:name="provider",type="string",JSONPath=".spec.provider" +// +kubebuilder:printcolumn:name="status",type="string",JSONPath=".status.conditions[?(@.type==\"Available\")].reason" +// +kubebuilder:printcolumn:name="age",type="date",JSONPath=".metadata.creationTimestamp" +type Region struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec RegionSpec `json:"spec"` + Status RegionStatus `json:"status,omitempty"` +} + +// RegionSpec defines metadata about the region. +type RegionSpec struct { + // Type defines the provider type. + Provider Provider `json:"provider"` + // Openstack is provider specific configuration for the region. + Openstack *RegionOpenstackSpec `json:"openstack,omitempty"` +} + +type RegionOpenstackSpec struct { + // Endpoint is the Keystone URL e.g. https://foo.bar:5000. + Endpoint string `json:"endpoint"` + // ServiceAccountSecretName points to the secret containing credentials + // required to perform the tasks the provider needs to perform. + ServiceAccountSecret *NamespacedObject `json:"serviceAccountSecret"` + // Identity is configuration for the identity service. + Identity *RegionOpenstackIdentitySpec `json:"identity,omitempty"` + // Compute is configuration for the compute service. + Compute *RegionOpenstackComputeSpec `json:"compute,omitempty"` + // Image is configuration for the image service. + Image *RegionOpenstackImageSpec `json:"image,omitempty"` +} + +type NamespacedObject struct { + // Namespace is the namespace in which the object resides. + Namespace string `json:"namespace"` + // Name is the name of the object. + Name string `json:"name"` +} + +type RegionOpenstackIdentitySpec struct { + // ClusterRoles are the roles required to be assigned to an application + // credential in order to provision, scale and deprovision a cluster, along + // with any required for CNI/CSI functionality. + ClusterRoles []string `json:"clusterRoles,omitempty"` +} + +type RegionOpenstackComputeSpec struct { + // ServerGroupPolicy defines the anti-affinity policy to use for + // scheduling cluster nodes. Defaults to "soft-anti-affinity". + ServerGroupPolicy *string `json:"serverGroupPolicy,omitempty"` + // FlavorExtraSpecsExclude discards any flavors with the listed + // extra specs keys. + FlavorExtraSpecsExclude []string `json:"flavorExtraSpecsExclude,omitempty"` + // GPUDescriptors defines a set of keys that can be probed to + // list GPU topology information. + GPUDescriptors []OpenstackGPUDescriptor `json:"gpuDescriptors,omitempty"` +} + +type OpenstackGPUDescriptor struct { + // Property is the property name to examine e.g. "resources.VGPU". + Property string `json:"property"` + // Expression describes how to extract the number of GPUs from the property + // if it exists. This must contain exactly one submatch that is a number + // e.g. "^(\d+)$". + Expression string `json:"expression"` +} + +type RegionOpenstackImageSpec struct { + // PropertiesInclude defines the set of properties that must all exist + // for an image to be advertised by the provider. + PropertiesInclude []string `json:"propertiesInclude,omitempty"` + // SigningKey defines a PEM encoded public ECDSA signing key used to verify + // the image is trusted. If specified, an image must contain the "digest" + // property, the value of which must be a base64 encoded ECDSA signature of + // the SHA256 hash of the image ID. + SigningKey []byte `json:"signingKey,omitempty"` +} + +// RegionStatus defines the status of the region. +type RegionStatus struct { + // Current service state of a region. + Conditions []unikornv1core.Condition `json:"conditions,omitempty"` +} diff --git a/pkg/apis/unikorn/v1alpha1/zz_generated.deepcopy.go b/pkg/apis/unikorn/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..4c65107 --- /dev/null +++ b/pkg/apis/unikorn/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,278 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by deepcopy-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + unikornv1alpha1 "github.com/unikorn-cloud/core/pkg/apis/unikorn/v1alpha1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NamespacedObject) DeepCopyInto(out *NamespacedObject) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespacedObject. +func (in *NamespacedObject) DeepCopy() *NamespacedObject { + if in == nil { + return nil + } + out := new(NamespacedObject) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpenstackGPUDescriptor) DeepCopyInto(out *OpenstackGPUDescriptor) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenstackGPUDescriptor. +func (in *OpenstackGPUDescriptor) DeepCopy() *OpenstackGPUDescriptor { + if in == nil { + return nil + } + out := new(OpenstackGPUDescriptor) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Region) DeepCopyInto(out *Region) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Region. +func (in *Region) DeepCopy() *Region { + if in == nil { + return nil + } + out := new(Region) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Region) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RegionList) DeepCopyInto(out *RegionList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Region, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegionList. +func (in *RegionList) DeepCopy() *RegionList { + if in == nil { + return nil + } + out := new(RegionList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RegionList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RegionOpenstackComputeSpec) DeepCopyInto(out *RegionOpenstackComputeSpec) { + *out = *in + if in.ServerGroupPolicy != nil { + in, out := &in.ServerGroupPolicy, &out.ServerGroupPolicy + *out = new(string) + **out = **in + } + if in.FlavorExtraSpecsExclude != nil { + in, out := &in.FlavorExtraSpecsExclude, &out.FlavorExtraSpecsExclude + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.GPUDescriptors != nil { + in, out := &in.GPUDescriptors, &out.GPUDescriptors + *out = make([]OpenstackGPUDescriptor, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegionOpenstackComputeSpec. +func (in *RegionOpenstackComputeSpec) DeepCopy() *RegionOpenstackComputeSpec { + if in == nil { + return nil + } + out := new(RegionOpenstackComputeSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RegionOpenstackIdentitySpec) DeepCopyInto(out *RegionOpenstackIdentitySpec) { + *out = *in + if in.ClusterRoles != nil { + in, out := &in.ClusterRoles, &out.ClusterRoles + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegionOpenstackIdentitySpec. +func (in *RegionOpenstackIdentitySpec) DeepCopy() *RegionOpenstackIdentitySpec { + if in == nil { + return nil + } + out := new(RegionOpenstackIdentitySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RegionOpenstackImageSpec) DeepCopyInto(out *RegionOpenstackImageSpec) { + *out = *in + if in.PropertiesInclude != nil { + in, out := &in.PropertiesInclude, &out.PropertiesInclude + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.SigningKey != nil { + in, out := &in.SigningKey, &out.SigningKey + *out = make([]byte, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegionOpenstackImageSpec. +func (in *RegionOpenstackImageSpec) DeepCopy() *RegionOpenstackImageSpec { + if in == nil { + return nil + } + out := new(RegionOpenstackImageSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RegionOpenstackSpec) DeepCopyInto(out *RegionOpenstackSpec) { + *out = *in + if in.ServiceAccountSecret != nil { + in, out := &in.ServiceAccountSecret, &out.ServiceAccountSecret + *out = new(NamespacedObject) + **out = **in + } + if in.Identity != nil { + in, out := &in.Identity, &out.Identity + *out = new(RegionOpenstackIdentitySpec) + (*in).DeepCopyInto(*out) + } + if in.Compute != nil { + in, out := &in.Compute, &out.Compute + *out = new(RegionOpenstackComputeSpec) + (*in).DeepCopyInto(*out) + } + if in.Image != nil { + in, out := &in.Image, &out.Image + *out = new(RegionOpenstackImageSpec) + (*in).DeepCopyInto(*out) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegionOpenstackSpec. +func (in *RegionOpenstackSpec) DeepCopy() *RegionOpenstackSpec { + if in == nil { + return nil + } + out := new(RegionOpenstackSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RegionSpec) DeepCopyInto(out *RegionSpec) { + *out = *in + if in.Openstack != nil { + in, out := &in.Openstack, &out.Openstack + *out = new(RegionOpenstackSpec) + (*in).DeepCopyInto(*out) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegionSpec. +func (in *RegionSpec) DeepCopy() *RegionSpec { + if in == nil { + return nil + } + out := new(RegionSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RegionStatus) DeepCopyInto(out *RegionStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]unikornv1alpha1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegionStatus. +func (in *RegionStatus) DeepCopy() *RegionStatus { + if in == nil { + return nil + } + out := new(RegionStatus) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go new file mode 100644 index 0000000..a5c5765 --- /dev/null +++ b/pkg/constants/constants.go @@ -0,0 +1,45 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package constants + +import ( + "fmt" + "os" + "path" +) + +var ( + // Application is the application name. + //nolint:gochecknoglobals + Application = path.Base(os.Args[0]) + + // Version is the application version set via the Makefile. + //nolint:gochecknoglobals + Version string + + // Revision is the git revision set via the Makefile. + //nolint:gochecknoglobals + Revision string +) + +// VersionString returns a canonical version string. It's based on +// HTTP's User-Agent so can be used to set that too, if this ever has to +// call out ot other micro services. +func VersionString() string { + return fmt.Sprintf("%s/%s (revision/%s)", Application, Version, Revision) +} diff --git a/pkg/handler/error.go b/pkg/handler/error.go new file mode 100644 index 0000000..2f68dd3 --- /dev/null +++ b/pkg/handler/error.go @@ -0,0 +1,39 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handler + +import ( + "net/http" + + "github.com/unikorn-cloud/core/pkg/server/errors" +) + +// NotFound is called from the router when a path is not found. +func NotFound(w http.ResponseWriter, r *http.Request) { + errors.HTTPNotFound().Write(w, r) +} + +// MethodNotAllowed is called from the router when a method is not found for a path. +func MethodNotAllowed(w http.ResponseWriter, r *http.Request) { + errors.HTTPMethodNotAllowed().Write(w, r) +} + +// HandleError is called when the router has trouble parsong paths. +func HandleError(w http.ResponseWriter, r *http.Request, err error) { + errors.OAuth2InvalidRequest("invalid path/query element").WithError(err).Write(w, r) +} diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go new file mode 100644 index 0000000..3882c6d --- /dev/null +++ b/pkg/handler/handler.go @@ -0,0 +1,146 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//nolint:revive,stylecheck +package handler + +import ( + "fmt" + "net/http" + "sort" + "time" + + "github.com/unikorn-cloud/core/pkg/server/errors" + "github.com/unikorn-cloud/core/pkg/server/middleware/openapi/oidc" + coreutil "github.com/unikorn-cloud/core/pkg/util" + "github.com/unikorn-cloud/region/pkg/handler/region" + "github.com/unikorn-cloud/region/pkg/openapi" + "github.com/unikorn-cloud/region/pkg/server/util" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type Handler struct { + // client gives cached access to Kubernetes. + client client.Client + + // options allows behaviour to be defined on the CLI. + options *Options + + // authorizerOptions allows access to the identity service for RBAC callbacks. + authorizerOptions *oidc.Options +} + +func New(client client.Client, options *Options, authorizerOptions *oidc.Options) (*Handler, error) { + h := &Handler{ + client: client, + options: options, + authorizerOptions: authorizerOptions, + } + + return h, nil +} + +func (h *Handler) setCacheable(w http.ResponseWriter) { + w.Header().Add("Cache-Control", fmt.Sprintf("max-age=%d", h.options.CacheMaxAge/time.Second)) + w.Header().Add("Cache-Control", "private") +} + +func (h *Handler) setUncacheable(w http.ResponseWriter) { + w.Header().Add("Cache-Control", "no-cache") +} + +func (h *Handler) GetApiV1Regions(w http.ResponseWriter, r *http.Request) { + result, err := region.NewClient(h.client).List(r.Context()) + if err != nil { + errors.HandleError(w, r, err) + return + } + + h.setUncacheable(w) + util.WriteJSONResponse(w, r, http.StatusOK, result) +} + +func (h *Handler) GetApiV1RegionsRegionIDFlavors(w http.ResponseWriter, r *http.Request, regionID openapi.RegionIDParameter) { + provider, err := region.NewClient(h.client).Provider(r.Context(), regionID) + if err != nil { + errors.HandleError(w, r, err) + return + } + + result, err := provider.Flavors(r.Context()) + if err != nil { + errors.HandleError(w, r, err) + return + } + + // Apply ordering guarantees. + sort.Stable(result) + + out := make(openapi.Flavors, 0, len(result)) + + for _, r := range result { + t := openapi.Flavor{ + Name: r.Name, + Cpus: r.CPUs, + Memory: int(r.Memory.Value()) >> 30, + Disk: int(r.Disk.Value()) / 1000000000, + } + + if r.GPUs != 0 { + t.Gpus = coreutil.ToPointer(r.GPUs) + } + + out = append(out, t) + } + + h.setCacheable(w) + util.WriteJSONResponse(w, r, http.StatusOK, out) +} + +func (h *Handler) GetApiV1RegionsRegionIDImages(w http.ResponseWriter, r *http.Request, regionID openapi.RegionIDParameter) { + provider, err := region.NewClient(h.client).Provider(r.Context(), regionID) + if err != nil { + errors.HandleError(w, r, err) + return + } + + result, err := provider.Images(r.Context()) + if err != nil { + errors.HandleError(w, r, err) + return + } + + // Apply ordering guarantees. + sort.Stable(result) + + out := make(openapi.Images, 0, len(result)) + + for _, r := range result { + out = append(out, openapi.Image{ + Name: r.Name, + Created: r.Created, + Modified: r.Modified, + Versions: openapi.ImageVersions{ + Kubernetes: r.KubernetesVersion, + }, + }) + } + + h.setCacheable(w) + util.WriteJSONResponse(w, r, http.StatusOK, out) +} diff --git a/pkg/handler/options.go b/pkg/handler/options.go new file mode 100644 index 0000000..d1b8d46 --- /dev/null +++ b/pkg/handler/options.go @@ -0,0 +1,36 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package handler + +import ( + "time" + + "github.com/spf13/pflag" +) + +// Options defines configurable handler options. +type Options struct { + // cacheMaxAge defines the max age for cachable items e.g. images and + // flavors don't change all that often. + CacheMaxAge time.Duration +} + +// AddFlags adds the options flags to the given flag set. +func (o *Options) AddFlags(f *pflag.FlagSet) { + f.DurationVar(&o.CacheMaxAge, "cache-max-age", 24*time.Hour, "How long to cache long-lived queries in the browser.") +} diff --git a/pkg/handler/region/region.go b/pkg/handler/region/region.go new file mode 100644 index 0000000..eda6e69 --- /dev/null +++ b/pkg/handler/region/region.go @@ -0,0 +1,138 @@ +/* +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package region + +import ( + "context" + "errors" + + coreopenapi "github.com/unikorn-cloud/core/pkg/openapi" + "github.com/unikorn-cloud/core/pkg/server/conversion" + unikornv1 "github.com/unikorn-cloud/region/pkg/apis/unikorn/v1alpha1" + "github.com/unikorn-cloud/region/pkg/openapi" + "github.com/unikorn-cloud/region/pkg/providers" + "github.com/unikorn-cloud/region/pkg/providers/openstack" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var ( + // ErrRegionNotFound is raised when a region doesn't exist. + ErrRegionNotFound = errors.New("region doesn't exist") + + // ErrRegionProviderUnimplmented is raised when you haven't written + // it yet! + ErrRegionProviderUnimplmented = errors.New("region provider unimplmented") +) + +type Client struct { + client client.Client + // region string +} + +func NewClient(client client.Client) *Client { + return &Client{ + client: client, + } +} + +// list is a canonical lister function that allows filtering to be applied +// in one place e.g. health, ownership, etc. +func (c *Client) list(ctx context.Context) (*unikornv1.RegionList, error) { + var regions unikornv1.RegionList + + if err := c.client.List(ctx, ®ions); err != nil { + return nil, err + } + + return ®ions, nil +} + +func findRegion(regions *unikornv1.RegionList, region string) (*unikornv1.Region, error) { + for i := range regions.Items { + if regions.Items[i].Name == region { + return ®ions.Items[i], nil + } + } + + return nil, ErrRegionNotFound +} + +//nolint:gochecknoglobals +var cache = map[string]providers.Provider{} + +func (c Client) newProvider(region *unikornv1.Region) (providers.Provider, error) { + //nolint:gocritic + switch region.Spec.Provider { + case unikornv1.ProviderOpenstack: + return openstack.New(c.client, region), nil + } + + return nil, ErrRegionProviderUnimplmented +} + +func (c *Client) Provider(ctx context.Context, regionName string) (providers.Provider, error) { + regions, err := c.list(ctx) + if err != nil { + return nil, err + } + + region, err := findRegion(regions, regionName) + if err != nil { + return nil, err + } + + if provider, ok := cache[region.Name]; ok { + return provider, nil + } + + provider, err := c.newProvider(region) + if err != nil { + return nil, err + } + + cache[region.Name] = provider + + return provider, nil +} + +func convert(in *unikornv1.Region) *openapi.RegionRead { + out := &openapi.RegionRead{ + Metadata: conversion.ResourceReadMetadata(in, coreopenapi.ResourceProvisioningStatusProvisioned), + } + + return out +} + +func convertList(in *unikornv1.RegionList) openapi.Regions { + out := make(openapi.Regions, 0, len(in.Items)) + + for i := range in.Items { + out = append(out, *convert(&in.Items[i])) + } + + return out +} + +func (c *Client) List(ctx context.Context) (openapi.Regions, error) { + regions, err := c.list(ctx) + if err != nil { + return nil, err + } + + return convertList(regions), nil +} diff --git a/pkg/openapi/client.go b/pkg/openapi/client.go new file mode 100644 index 0000000..66a30d1 --- /dev/null +++ b/pkg/openapi/client.go @@ -0,0 +1,519 @@ +// Package openapi provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen version v1.16.2 DO NOT EDIT. +package openapi + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/oapi-codegen/runtime" + externalRef0 "github.com/unikorn-cloud/core/pkg/openapi" +) + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // GetApiV1Regions request + GetApiV1Regions(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetApiV1RegionsRegionIDFlavors request + GetApiV1RegionsRegionIDFlavors(ctx context.Context, regionID RegionIDParameter, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetApiV1RegionsRegionIDImages request + GetApiV1RegionsRegionIDImages(ctx context.Context, regionID RegionIDParameter, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) GetApiV1Regions(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetApiV1RegionsRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetApiV1RegionsRegionIDFlavors(ctx context.Context, regionID RegionIDParameter, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetApiV1RegionsRegionIDFlavorsRequest(c.Server, regionID) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetApiV1RegionsRegionIDImages(ctx context.Context, regionID RegionIDParameter, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetApiV1RegionsRegionIDImagesRequest(c.Server, regionID) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewGetApiV1RegionsRequest generates requests for GetApiV1Regions +func NewGetApiV1RegionsRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/regions") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetApiV1RegionsRegionIDFlavorsRequest generates requests for GetApiV1RegionsRegionIDFlavors +func NewGetApiV1RegionsRegionIDFlavorsRequest(server string, regionID RegionIDParameter) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "regionID", runtime.ParamLocationPath, regionID) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/regions/%s/flavors", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetApiV1RegionsRegionIDImagesRequest generates requests for GetApiV1RegionsRegionIDImages +func NewGetApiV1RegionsRegionIDImagesRequest(server string, regionID RegionIDParameter) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "regionID", runtime.ParamLocationPath, regionID) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/v1/regions/%s/images", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // GetApiV1RegionsWithResponse request + GetApiV1RegionsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetApiV1RegionsResponse, error) + + // GetApiV1RegionsRegionIDFlavorsWithResponse request + GetApiV1RegionsRegionIDFlavorsWithResponse(ctx context.Context, regionID RegionIDParameter, reqEditors ...RequestEditorFn) (*GetApiV1RegionsRegionIDFlavorsResponse, error) + + // GetApiV1RegionsRegionIDImagesWithResponse request + GetApiV1RegionsRegionIDImagesWithResponse(ctx context.Context, regionID RegionIDParameter, reqEditors ...RequestEditorFn) (*GetApiV1RegionsRegionIDImagesResponse, error) +} + +type GetApiV1RegionsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *RegionsResponse + JSON401 *externalRef0.UnauthorizedResponse + JSON500 *externalRef0.InternalServerErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetApiV1RegionsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetApiV1RegionsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetApiV1RegionsRegionIDFlavorsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *FlavorsResponse + JSON400 *externalRef0.BadRequestResponse + JSON401 *externalRef0.UnauthorizedResponse + JSON500 *externalRef0.InternalServerErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetApiV1RegionsRegionIDFlavorsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetApiV1RegionsRegionIDFlavorsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetApiV1RegionsRegionIDImagesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ImagesResponse + JSON400 *externalRef0.BadRequestResponse + JSON401 *externalRef0.UnauthorizedResponse + JSON500 *externalRef0.InternalServerErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetApiV1RegionsRegionIDImagesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetApiV1RegionsRegionIDImagesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// GetApiV1RegionsWithResponse request returning *GetApiV1RegionsResponse +func (c *ClientWithResponses) GetApiV1RegionsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetApiV1RegionsResponse, error) { + rsp, err := c.GetApiV1Regions(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetApiV1RegionsResponse(rsp) +} + +// GetApiV1RegionsRegionIDFlavorsWithResponse request returning *GetApiV1RegionsRegionIDFlavorsResponse +func (c *ClientWithResponses) GetApiV1RegionsRegionIDFlavorsWithResponse(ctx context.Context, regionID RegionIDParameter, reqEditors ...RequestEditorFn) (*GetApiV1RegionsRegionIDFlavorsResponse, error) { + rsp, err := c.GetApiV1RegionsRegionIDFlavors(ctx, regionID, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetApiV1RegionsRegionIDFlavorsResponse(rsp) +} + +// GetApiV1RegionsRegionIDImagesWithResponse request returning *GetApiV1RegionsRegionIDImagesResponse +func (c *ClientWithResponses) GetApiV1RegionsRegionIDImagesWithResponse(ctx context.Context, regionID RegionIDParameter, reqEditors ...RequestEditorFn) (*GetApiV1RegionsRegionIDImagesResponse, error) { + rsp, err := c.GetApiV1RegionsRegionIDImages(ctx, regionID, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetApiV1RegionsRegionIDImagesResponse(rsp) +} + +// ParseGetApiV1RegionsResponse parses an HTTP response from a GetApiV1RegionsWithResponse call +func ParseGetApiV1RegionsResponse(rsp *http.Response) (*GetApiV1RegionsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetApiV1RegionsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest RegionsResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest externalRef0.UnauthorizedResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest externalRef0.InternalServerErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetApiV1RegionsRegionIDFlavorsResponse parses an HTTP response from a GetApiV1RegionsRegionIDFlavorsWithResponse call +func ParseGetApiV1RegionsRegionIDFlavorsResponse(rsp *http.Response) (*GetApiV1RegionsRegionIDFlavorsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetApiV1RegionsRegionIDFlavorsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest FlavorsResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest externalRef0.BadRequestResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest externalRef0.UnauthorizedResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest externalRef0.InternalServerErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetApiV1RegionsRegionIDImagesResponse parses an HTTP response from a GetApiV1RegionsRegionIDImagesWithResponse call +func ParseGetApiV1RegionsRegionIDImagesResponse(rsp *http.Response) (*GetApiV1RegionsRegionIDImagesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetApiV1RegionsRegionIDImagesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ImagesResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest externalRef0.BadRequestResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest externalRef0.UnauthorizedResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest externalRef0.InternalServerErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} diff --git a/pkg/openapi/config.yaml b/pkg/openapi/config.yaml new file mode 100644 index 0000000..0aa29e8 --- /dev/null +++ b/pkg/openapi/config.yaml @@ -0,0 +1,3 @@ +package: generated +import-mapping: + https://raw.githubusercontent.com/unikorn-cloud/core/main/pkg/openapi/common.spec.yaml: github.com/unikorn-cloud/core/pkg/openapi diff --git a/pkg/openapi/router.go b/pkg/openapi/router.go new file mode 100644 index 0000000..99099eb --- /dev/null +++ b/pkg/openapi/router.go @@ -0,0 +1,253 @@ +// Package openapi provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen version v1.16.2 DO NOT EDIT. +package openapi + +import ( + "context" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/oapi-codegen/runtime" +) + +// ServerInterface represents all server handlers. +type ServerInterface interface { + + // (GET /api/v1/regions) + GetApiV1Regions(w http.ResponseWriter, r *http.Request) + + // (GET /api/v1/regions/{regionID}/flavors) + GetApiV1RegionsRegionIDFlavors(w http.ResponseWriter, r *http.Request, regionID RegionIDParameter) + + // (GET /api/v1/regions/{regionID}/images) + GetApiV1RegionsRegionIDImages(w http.ResponseWriter, r *http.Request, regionID RegionIDParameter) +} + +// Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. + +type Unimplemented struct{} + +// (GET /api/v1/regions) +func (_ Unimplemented) GetApiV1Regions(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// (GET /api/v1/regions/{regionID}/flavors) +func (_ Unimplemented) GetApiV1RegionsRegionIDFlavors(w http.ResponseWriter, r *http.Request, regionID RegionIDParameter) { + w.WriteHeader(http.StatusNotImplemented) +} + +// (GET /api/v1/regions/{regionID}/images) +func (_ Unimplemented) GetApiV1RegionsRegionIDImages(w http.ResponseWriter, r *http.Request, regionID RegionIDParameter) { + w.WriteHeader(http.StatusNotImplemented) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// GetApiV1Regions operation middleware +func (siw *ServerInterfaceWrapper) GetApiV1Regions(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + ctx = context.WithValue(ctx, Oauth2AuthenticationScopes, []string{}) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetApiV1Regions(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + +// GetApiV1RegionsRegionIDFlavors operation middleware +func (siw *ServerInterfaceWrapper) GetApiV1RegionsRegionIDFlavors(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // ------------- Path parameter "regionID" ------------- + var regionID RegionIDParameter + + err = runtime.BindStyledParameterWithLocation("simple", false, "regionID", runtime.ParamLocationPath, chi.URLParam(r, "regionID"), ®ionID) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "regionID", Err: err}) + return + } + + ctx = context.WithValue(ctx, Oauth2AuthenticationScopes, []string{}) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetApiV1RegionsRegionIDFlavors(w, r, regionID) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + +// GetApiV1RegionsRegionIDImages operation middleware +func (siw *ServerInterfaceWrapper) GetApiV1RegionsRegionIDImages(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var err error + + // ------------- Path parameter "regionID" ------------- + var regionID RegionIDParameter + + err = runtime.BindStyledParameterWithLocation("simple", false, "regionID", runtime.ParamLocationPath, chi.URLParam(r, "regionID"), ®ionID) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "regionID", Err: err}) + return + } + + ctx = context.WithValue(ctx, Oauth2AuthenticationScopes, []string{}) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetApiV1RegionsRegionIDImages(w, r, regionID) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r.WithContext(ctx)) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{}) +} + +type ChiServerOptions struct { + BaseURL string + BaseRouter chi.Router + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseRouter: r, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseURL: baseURL, + BaseRouter: r, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler { + r := options.BaseRouter + + if r == nil { + r = chi.NewRouter() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/regions", wrapper.GetApiV1Regions) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/regions/{regionID}/flavors", wrapper.GetApiV1RegionsRegionIDFlavors) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/regions/{regionID}/images", wrapper.GetApiV1RegionsRegionIDImages) + }) + + return r +} diff --git a/pkg/openapi/schema.go b/pkg/openapi/schema.go new file mode 100644 index 0000000..6ad04b8 --- /dev/null +++ b/pkg/openapi/schema.go @@ -0,0 +1,148 @@ +// Package openapi provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen version v1.16.2 DO NOT EDIT. +package openapi + +import ( + "bytes" + "compress/gzip" + "encoding/base64" + "fmt" + "net/url" + "path" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + externalRef0 "github.com/unikorn-cloud/core/pkg/openapi" +) + +// Base64 encoded, gzipped, json marshaled Swagger object +var swaggerSpec = []string{ + + "H4sIAAAAAAAC/+xa3Y/btrL/VwjeAr0XkOTv/dBLkZv09gYnbYJkm4dm9ywocWyxS5EqSXnXXfh/PxhS", + "smVbdpxtXw5w3lYSOZyP3wx/M95nmuuy0gqUszR9phUzrAQHxj8ZWAit3r750L7FlxxsbkTlhFY0pTcF", + "kLCMKFZCQiMq8H3FXEEjiu9ouhFEI2rgj1oY4DR1poaI2ryAkqHg7wzMaUr/a7BVaRC+2sFDnYFR4MD+", + "wkrY6rNer1GkrbSy4HXOGP8If9Rg3cfmNb7NtXKgHP7JqkqKnKH+g98tGvFM4YmVlfQrwRhtaEqFWjIp", + "+L0JwmgUvtzvmt98JZnmK9JsoetzrQpneRt2xX7sip0zIYGTsIn4I7z2EdGGOO//sJprsERpR9BaJtSt", + "YlJuVqDTyVyA5DZBFeeSLbWxL/HSl2eaV7Wl6TSiXNgHmo6HEV34V6OICk5Tes2u8ovJ5TCeDi9m8ZRP", + "WXzN2TC+vLi84vPpMOfXnEa0hFKbFU0n4w1YFsk0KcSiKKFM2Gg4TEaLZDRcZHR9d65jG9v6XPuKSGEd", + "0XPSLPLOECVbwIt9YYA5RDQdD8eTeDiOx+Ob0TgdTtPR5DfaOIRdTOfX44vreHIBw3g6GY3j7IqP4tmY", + "X0/47OI6u8zQIZqLueiRNpqlo6vftjlVZ/V4PJzGy1EyniUX8aKq49l4llzNkuEsvsyBT0ezKY3oEowV", + "Wvns2OYRTWmzE0UuBRfsjRFLzHG6EUPX5/s8uPC0y8Ma4grmCDNAUAxzIpNAHoUriCuEJZVkbq5NGQKj", + "HBjF5CcwSzA/YsL8tby2XtB9eOxP6ibhnCZOP4AiuWSi/Buy+pUitYKnCnIHnPhlROd5bQzwKNheshXJ", + "gLCdlc4wZQUo1+xhit8qXGnrPAfgRCvCiAFnVgl5Ow+ShFXfO5/8ObMQkUoCs1gJKm0cEY4wi8cIa2vw", + "ng41+qU5UIJjnDnvHJ8PQqsb4YEaYHwZT0Y3o2E6naXTGcL4wDf6aVVqo5XIiRNg4glBgTkgAkjGLHAi", + "FHmHGK20lkmbWPnl7OIKxjyeX7Msns4mPL5mExbPRpPL2fzyajq+yDpp8xA/hnpeGb0UmBlCLT455rB6", + "bV8C/xbwN847jf5mkfd2rVjtCm3En8D/GqBZnoO19xwUVo1+RONZoFwjrblR/g5E98ltr6WgWJNDBbME", + "niq8hBK63pxsO5bsU4ufQIEReQP6EqxlC4gOaoZG48ZJCGgFxgk4JfUVQXYDjVTrjFAL1Iwpjn9p5XX/", + "/5ubD82SXHNIiK881hetAMVm4Xt0wZjYCnIxb/wQkawO9S3IBR40Rf2MAMfMilgPOC/ckrk25NWHt5Zo", + "VwA6j6FwbaGVC5xkq+YstBRUXdL0Sw9J6eLqPpdYNWh0gJFa2brCQgC4N6Dv3q0q8PQtyLS59s97BdNB", + "WWnDjJCr+1qxJROSZbK7cXNq+2JhmHJ7p/p37ZFKu/u5rhWqlms1lyJ3nhu4QvN7/Mqk1I8HqpfABWuF", + "zLXJBOeg6F1E/buUhvAi0HuyYh8Zn8Fk6PMGaSR8zRAVGGkvAZ2/J3vd5bRf6PGLZauWzn6H3G1JWB9K", + "w5dDWAfi1UfDVV1mYLDQvP7wq+1oinfoAslyy9b6drNS18qXKagKKMEwSXA11tyf/rdf2uIMXX768KuN", + "iJh7YmrBX0kGfHYoraBfMNb1PrG1En/U0PiGvH3TE44tpTxtZVjlrRNHzAs3Rp+YRoG24zmNCMHb2ycK", + "0duo2MTjODBsHzIO+GtEhYPSnkeKUXhzGjOGrTbst+coFQhbDwhbxnvgHFECeSwgFEe/mzwyS5oNCXkb", + "GKCvh4woeARDWr5AHO62ha4lv1UZkDlb6hrbFr1EMEkOpqWQDFkkrMijkLLtd0hdcebwo+Jk7kUva6nA", + "sExIgbonoU6UzNGU4toYj+wD0VcQGCw7BsANhT/XPZJZR9pt5yt5HKBB9BF87rYEX+X0n9vFJ4HdIKJj", + "feeYPoTvyj6wwgOFNBJISy8PodjtaPo8sf1OLJQMyUorNSHkBqlyQBzy7hpvdi4M5E6uQqDCDbwSanGr", + "/rGVlcvaIi8tmWILMAFzj9o8SM04QX5qsbawzcIdhtAblN0mrLeq+hWE+yUbK75agDou2jvlaFxOFp5v", + "7OLOLE+hCPVUp63671gG8jOTdV+xCpMRgjEKIZK4Gt/WEBG3qkTOpFyFGCPjMmB1bfKQJo05OVMkg1sl", + "FIen0HBgpiL0kPZ5+DGHLSlN6T+/DOPrV/FvLP7z7r9/SLdP8X1y9zyMLkbrzor/+eG7vqgfm2z1GNiB", + "n89s8nNtnW8XG9vf/PKprYWBy8oVQd5kfA9I8oIZliP9jZoL2iJXL1ZVAcpGyEmN80AGxdsSvdmES8Ou", + "yK/Bc7FoWUcuJh3Z6DMJauEK9FbJnt75B5peTCJaCtU+jnqcETqkj8B4n/3h62EJ6Paep5u0EHCU/3O7", + "Zz9ZNsL6cqNt804kx6bJOxP2HZN7sN+q/HPHxP1RYYPiVvEGC4hwJiX2FVukG2C8KVVGuHAf7vryJEMO", + "w95GVudTM7zQ/sEnGasXJZrpswfBGuBUal8ulIMnl5y60c6bBXfqwX4YvaC7jgc/9HT7ffZ1pwK+SwOM", + "KtvY3W2/avWg9KPamyV0H/01yGHvc+gR7noToAei6TNlUr6f+0HLOQDfgjt67mNum9lMnwM8CXPdSHcJ", + "3NnchIOElxzk933LQV9hahvhR8ha1YuLc7zcg6hehrTj8t4DD0vNXfSNae4zu5msQF4b4VafUN8Q9TAj", + "2Z3WHHrtfQUmEPHGBtuONzJgBkwzytkdJvlASf3oz2lnD/7La83h4OWvRtKUFs5VNh0MBEdBbpXUSjxo", + "o+Jc6pon2iwGQeXBcjzY2U8j6gcTeBzWS9ToBTL9Pu+t1vH+U5hxCTXXh955jWLa37q4sDk2JStfTI2u", + "nS8XYJYilAgnnES5nTv7Y9j6KSzCwrylyDSlw2SUDBGRugLFKkFTOkmGySRQjsL7d8AqMViOBkfvoXAG", + "ZtFcKPD0E41viG4zXV6AXhhWFUiIiJ8nI01Y+MeKGSd8XG/Vj8KPox7Zqh0p+3StjCiFE0sIlEnYQKmc", + "JsxanQssmRuU2jovCLO3audQqXMmAXkE0WbBlLA+uN9bTFcwS+AkkzrDyosJWDsg4HJUieVFG4OCWSKc", + "JfpRhaqN7eFGhYbACBf5HGkGp+R9BeqTY/lDFMborQAL/vbODXj4MGmJ1Wi2WtiGFRIPvGDohs4TKzGY", + "eEXcKlsw7Fa9z4krjK4XBXksmAPk6yXkBZpaoss2g7MwSETq6Xe1hiCGFuAOI/wOeUbI+A3R0G3ivuV+", + "bupeVeLzqMEC3fuBdDwcHqtwm3WD/R8D1hGdDkdf39c71l5HdHbOoad+6+kWNn8P9pe0L3frO1y6lyiD", + "5/YX6PXg6GTldYO0ZrjTJLM9GQnrQ5Hv7GxaCT8C36oHHNPEeNRuJuNfi93HRuv/a3R+SSj3f+f1oTxj", + "X88P6f9WKIh2/pnhCHPaLhkc/rODF3ICScc65RZIYQjzAhx1G+xzYJSci6MwfXsRjPZ+If8Pir4BRev1", + "vwIAAP//I4sGhmgjAAA=", +} + +// GetSwagger returns the content of the embedded swagger specification file +// or error if failed to decode +func decodeSpec() ([]byte, error) { + zipped, err := base64.StdEncoding.DecodeString(strings.Join(swaggerSpec, "")) + if err != nil { + return nil, fmt.Errorf("error base64 decoding spec: %w", err) + } + zr, err := gzip.NewReader(bytes.NewReader(zipped)) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %w", err) + } + var buf bytes.Buffer + _, err = buf.ReadFrom(zr) + if err != nil { + return nil, fmt.Errorf("error decompressing spec: %w", err) + } + + return buf.Bytes(), nil +} + +var rawSpec = decodeSpecCached() + +// a naive cached of a decoded swagger spec +func decodeSpecCached() func() ([]byte, error) { + data, err := decodeSpec() + return func() ([]byte, error) { + return data, err + } +} + +// Constructs a synthetic filesystem for resolving external references when loading openapi specifications. +func PathToRawSpec(pathToFile string) map[string]func() ([]byte, error) { + res := make(map[string]func() ([]byte, error)) + if len(pathToFile) > 0 { + res[pathToFile] = rawSpec + } + + pathPrefix := path.Dir(pathToFile) + + for rawPath, rawFunc := range externalRef0.PathToRawSpec(path.Join(pathPrefix, "https://raw.githubusercontent.com/unikorn-cloud/core/main/pkg/openapi/common.spec.yaml")) { + if _, ok := res[rawPath]; ok { + // it is not possible to compare functions in golang, so always overwrite the old value + } + res[rawPath] = rawFunc + } + return res +} + +// GetSwagger returns the Swagger specification corresponding to the generated code +// in this file. The external references of Swagger specification are resolved. +// The logic of resolving external references is tightly connected to "import-mapping" feature. +// Externally referenced files must be embedded in the corresponding golang packages. +// Urls can be supported but this task was out of the scope. +func GetSwagger() (swagger *openapi3.T, err error) { + resolvePath := PathToRawSpec("") + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(loader *openapi3.Loader, url *url.URL) ([]byte, error) { + pathToFile := url.String() + pathToFile = path.Clean(pathToFile) + getSpec, ok := resolvePath[pathToFile] + if !ok { + err1 := fmt.Errorf("path not found: %s", pathToFile) + return nil, err1 + } + return getSpec() + } + var specData []byte + specData, err = rawSpec() + if err != nil { + return + } + swagger, err = loader.LoadFromData(specData) + if err != nil { + return + } + return +} diff --git a/pkg/openapi/server.spec.yaml b/pkg/openapi/server.spec.yaml new file mode 100644 index 0000000..ba43ddc --- /dev/null +++ b/pkg/openapi/server.spec.yaml @@ -0,0 +1,223 @@ +openapi: 3.0.3 +info: + title: Kubernetes Region Service API + description: |- + Cloud region discovery and routing service. + version: 0.1.0 +paths: + /api/v1/regions: + description: |- + Regions define a cloud. This may be geographical or any logical partition. + Either way this is the primitive that is used to associate metadata such as + geographical locale, an organisation's reserved blob of compute etc. + Each region has its own provider associated with it, for example OpenStack, and + its own set of credentials so things can be scoped to a specific slice of a + shared cloud through whatever mechanism is available on that cloud provider. + get: + description: |- + List all regions. + security: + - oauth2Authentication: [] + responses: + '200': + $ref: '#/components/responses/regionsResponse' + '401': + $ref: 'https://raw.githubusercontent.com/unikorn-cloud/core/main/pkg/openapi/common.spec.yaml#/components/responses/unauthorizedResponse' + '500': + $ref: 'https://raw.githubusercontent.com/unikorn-cloud/core/main/pkg/openapi/common.spec.yaml#/components/responses/internalServerErrorResponse' + /api/v1/regions/{regionID}/flavors: + description: Compute flavor services. + parameters: + - $ref: '#/components/parameters/regionIDParameter' + get: + description: |- + Lists all compute flavors that the authenticated user has access to + security: + - oauth2Authentication: [] + responses: + '200': + $ref: '#/components/responses/flavorsResponse' + '400': + $ref: 'https://raw.githubusercontent.com/unikorn-cloud/core/main/pkg/openapi/common.spec.yaml#/components/responses/badRequestResponse' + '401': + $ref: 'https://raw.githubusercontent.com/unikorn-cloud/core/main/pkg/openapi/common.spec.yaml#/components/responses/unauthorizedResponse' + '500': + $ref: 'https://raw.githubusercontent.com/unikorn-cloud/core/main/pkg/openapi/common.spec.yaml#/components/responses/internalServerErrorResponse' + /api/v1/regions/{regionID}/images: + description: Compute image services. + parameters: + - $ref: '#/components/parameters/regionIDParameter' + get: + description: |- + Lists all compute images that the authenticated user has access to. + security: + - oauth2Authentication: [] + responses: + '200': + $ref: '#/components/responses/imagesResponse' + '400': + $ref: 'https://raw.githubusercontent.com/unikorn-cloud/core/main/pkg/openapi/common.spec.yaml#/components/responses/badRequestResponse' + '401': + $ref: 'https://raw.githubusercontent.com/unikorn-cloud/core/main/pkg/openapi/common.spec.yaml#/components/responses/unauthorizedResponse' + '500': + $ref: 'https://raw.githubusercontent.com/unikorn-cloud/core/main/pkg/openapi/common.spec.yaml#/components/responses/internalServerErrorResponse' +components: + parameters: + regionIDParameter: + name: regionID + in: path + description: |- + The region name. + required: true + schema: + $ref: '#/components/schemas/kubernetesNameParameter' + schemas: + kubernetesNameParameter: + description: A Kubernetes name. Must be a valid DNS containing only lower case characters, numbers or hyphens, start and end with a character or number, and be at most 63 characters in length. + type: string + minLength: 1 + maxLength: 63 + regionRead: + description: A region. + type: object + required: + - metadata + properties: + metadata: + $ref: 'https://raw.githubusercontent.com/unikorn-cloud/core/main/pkg/openapi/common.spec.yaml#/components/schemas/resourceReadMetadata' + regions: + description: A list of regions. + type: array + items: + $ref: '#/components/schemas/regionRead' + imageVersions: + description: Image version metadata. + type: object + required: + - kubernetes + - nvidiaDriver + properties: + kubernetes: + description: |- + The kubernetes semantic version. This should be used directly when specifying + Kubernetes cluster managers and workload pools in a cluster specification. + type: string + nvidiaDriver: + description: The nvidia driver version. + type: string + image: + description: An image. + type: object + required: + - id + - name + - created + - modified + - versions + properties: + id: + description: The unique image ID. + type: string + name: + description: The image name. + type: string + created: + description: |- + Time when the image was created. Images with a newer creation time should + be favoured over older images as they will contain updates and fewer vulnerabilities. + type: string + format: date-time + modified: + description: Time when the image was last modified. + type: string + format: date-time + versions: + $ref: '#/components/schemas/imageVersions' + images: + description: A list of images that are compatible with this platform. + type: array + items: + $ref: '#/components/schemas/image' + flavor: + description: A flavor. + type: object + required: + - id + - name + - cpus + - memory + - disk + properties: + id: + description: The unique flavor ID. + type: string + name: + description: The flavor name. + type: string + cpus: + description: The number of CPUs. + type: integer + memory: + description: The amount of memory in GiB. + type: integer + disk: + description: The amount of ephemeral disk in GB. + type: integer + gpus: + description: The number of GPUs, if not set there are none. + type: integer + flavors: + description: A list of flavors. + type: array + items: + $ref: '#/components/schemas/flavor' + responses: + regionsResponse: + description: A list of regions. + content: + application/json: + schema: + $ref: '#/components/schemas/regions' + example: + - metadata: + id: c7568e2d-f9ab-453d-9a3a-51375f78426b + name: uk-west + description: An oxymoronic tier-3 datacenter based in Liverpool. + creationTime: 2023-07-31T10:45:45Z + provisioningStatus: provisioned + imagesResponse: + description: A list of images that are compatible with this platform. + content: + application/json: + schema: + $ref: '#/components/schemas/images' + example: + - created: 2023-02-22T12:04:13Z + id: a64f9269-36e0-4312-b8d1-52d93d569b7b + modified: 2023-02-22T12:15:18Z + name: ubu2204-v1.25.6-gpu-525.85.05-7ced4154 + versions: + kubernetes: v1.25.6 + nvidiaDriver: 525.85.05 + flavorsResponse: + description: A list of flavors. + content: + application/json: + schema: + $ref: '#/components/schemas/flavors' + example: + - cpus: 4 + disk: 20 + gpus: 1 + id: 9a8c6370-4065-4d4a-9da0-7678df40cd9d + memory: 32 + name: g.4.highmem.a100.1g.10gb + securitySchemes: + oauth2Authentication: + description: Operation requires OAuth2 bearer token authentication. + type: oauth2 + flows: + authorizationCode: + authorizationUrl: https://identity.unikorn-cloud.org/oauth2/v2/authorization + tokenUrl: https://identity.unikorn-cloud.org/oauth2/v2/token + scopes: {} diff --git a/pkg/openapi/types.go b/pkg/openapi/types.go new file mode 100644 index 0000000..f082c8c --- /dev/null +++ b/pkg/openapi/types.go @@ -0,0 +1,94 @@ +// Package openapi provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen version v1.16.2 DO NOT EDIT. +package openapi + +import ( + "time" + + externalRef0 "github.com/unikorn-cloud/core/pkg/openapi" +) + +const ( + Oauth2AuthenticationScopes = "oauth2Authentication.Scopes" +) + +// Flavor A flavor. +type Flavor struct { + // Cpus The number of CPUs. + Cpus int `json:"cpus"` + + // Disk The amount of ephemeral disk in GB. + Disk int `json:"disk"` + + // Gpus The number of GPUs, if not set there are none. + Gpus *int `json:"gpus,omitempty"` + + // Id The unique flavor ID. + Id string `json:"id"` + + // Memory The amount of memory in GiB. + Memory int `json:"memory"` + + // Name The flavor name. + Name string `json:"name"` +} + +// Flavors A list of flavors. +type Flavors = []Flavor + +// Image An image. +type Image struct { + // Created Time when the image was created. Images with a newer creation time should + // be favoured over older images as they will contain updates and fewer vulnerabilities. + Created time.Time `json:"created"` + + // Id The unique image ID. + Id string `json:"id"` + + // Modified Time when the image was last modified. + Modified time.Time `json:"modified"` + + // Name The image name. + Name string `json:"name"` + + // Versions Image version metadata. + Versions ImageVersions `json:"versions"` +} + +// ImageVersions Image version metadata. +type ImageVersions struct { + // Kubernetes The kubernetes semantic version. This should be used directly when specifying + // Kubernetes cluster managers and workload pools in a cluster specification. + Kubernetes string `json:"kubernetes"` + + // NvidiaDriver The nvidia driver version. + NvidiaDriver string `json:"nvidiaDriver"` +} + +// Images A list of images that are compatible with this platform. +type Images = []Image + +// KubernetesNameParameter A Kubernetes name. Must be a valid DNS containing only lower case characters, numbers or hyphens, start and end with a character or number, and be at most 63 characters in length. +type KubernetesNameParameter = string + +// RegionRead A region. +type RegionRead struct { + // Metadata Resource metadata valid for all reads. + Metadata externalRef0.ResourceReadMetadata `json:"metadata"` +} + +// Regions A list of regions. +type Regions = []RegionRead + +// RegionIDParameter A Kubernetes name. Must be a valid DNS containing only lower case characters, numbers or hyphens, start and end with a character or number, and be at most 63 characters in length. +type RegionIDParameter = KubernetesNameParameter + +// FlavorsResponse A list of flavors. +type FlavorsResponse = Flavors + +// ImagesResponse A list of images that are compatible with this platform. +type ImagesResponse = Images + +// RegionsResponse A list of regions. +type RegionsResponse = Regions diff --git a/pkg/providers/constants.go b/pkg/providers/constants.go new file mode 100644 index 0000000..bedd0e9 --- /dev/null +++ b/pkg/providers/constants.go @@ -0,0 +1,38 @@ +/* +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package providers + +import ( + "maps" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + MetdataDomain = "provider.unikorn-cloud.org" +) + +func GetAnnotations(resource metav1.Object) map[string]string { + annotations := maps.Clone(resource.GetAnnotations()) + + maps.DeleteFunc(annotations, func(k, v string) bool { + return !strings.Contains(k, MetdataDomain) + }) + + return annotations +} diff --git a/pkg/providers/helpers.go b/pkg/providers/helpers.go new file mode 100644 index 0000000..5dffdc7 --- /dev/null +++ b/pkg/providers/helpers.go @@ -0,0 +1,53 @@ +/* +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package providers + +func (l FlavorList) Len() int { + return len(l) +} + +func (l FlavorList) Less(i, j int) bool { + // Sort by GPUs, we want these to have precedence, we are selling GPUs + // after all. Those with the smallest number of GPUs go first, we want to + // prevent over provisioning. + if l[i].GPUs < l[j].GPUs { + return true + } + + // If the GPUs are the same, sort by CPUs. + if l[i].CPUs < l[j].CPUs { + return true + } + + return false +} + +func (l FlavorList) Swap(i, j int) { + l[i], l[j] = l[j], l[i] +} + +func (l ImageList) Len() int { + return len(l) +} + +func (l ImageList) Less(i, j int) bool { + return l[i].Created.Before(l[j].Created) +} + +func (l ImageList) Swap(i, j int) { + l[i], l[j] = l[j], l[i] +} diff --git a/pkg/providers/interfaces.go b/pkg/providers/interfaces.go new file mode 100644 index 0000000..0698517 --- /dev/null +++ b/pkg/providers/interfaces.go @@ -0,0 +1,31 @@ +/* +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package providers + +import ( + "context" +) + +// Providers are expected to provide a provider agnostic manner. +// They are also expected to provide any caching or memoization required +// to provide high performance and a decent UX. +type Provider interface { + // Flavors list all available flavors. + Flavors(ctx context.Context) (FlavorList, error) + // Images lists all available images. + Images(ctx context.Context) (ImageList, error) +} diff --git a/pkg/providers/openstack/README.md b/pkg/providers/openstack/README.md new file mode 100644 index 0000000..f170158 --- /dev/null +++ b/pkg/providers/openstack/README.md @@ -0,0 +1,87 @@ +# Unikorn OpenStack Provider + +Provides a driver for OpenStack based regions. + +## Initial Setup + +It is envisoned that an OpenStack cluster may be used for things other than the exclusive use of Unikorn, and as such it tries to respect this as much as possible. +In particular we want to allow different instances of Unikorn to cohabit to support, for example, staging environments. + +You will need to install the [domain manager](https://docs.scs.community/standards/scs-0302-v1-domain-manager-role/) policy defined by SCS. +You will also need to edit this to allow the `_member_` role to be granted. + +### OpenStack Platform Configuration + +Start by selecting a unique name that will be used for the deployment's name, project, and domain: + +```bash +export USER=unikorn-staging +export DOMAIN=unikorn-staging +export PASSWORD=$(apg -n 1 -m 24) +``` + +Create the domain. +The use of project domains for projects deployed to provision Kubernetes cluster acheives a few aims. +First namespace isolation. +Second is a security consideration. +It is dangerous, anecdotally, to have a privileged process that has the power of deletion. +By limiting the scope of list operations to that of the project domain we limit our impact on other tenants on the system. +A domain may also aid in simplifying operations like auditing and capacity planning. + +```bash +DOMAIN_ID=$(openstack domain create ${DOMAIN} -f json | jq -r .id) +``` + +Crete the user. + +```bash +USER_ID=$(openstack user create --domain ${DOMAIN_ID} --password ${PASSWORD} ${USER} -f json | jq -r .id) +``` + +Grant any roles to the user. +When a Kubernetes cluster is provisioned, it will be done using application credentials, so ensure any required application credentials as configured for the region are explicitly associated with the user here. + +```bash +for role in _member_ member load-balancer_member manager; do + openstack role add --user ${USER_ID} --domain ${DOMAIN_ID} ${role} +done +``` + +### Unikorn Configuration + +When we create a `Region` of type `openstack`, it will require a secret that contains credentials. +This can be configured as follows. + +```bash +kubectl create secret generic -n unikorn uk-north-1-credentials \ + --from-literal=domain-id=${DOMAIN_ID} \ + --from-literal=user-id=${USER_ID} \ + --from-literal=password=${PASSWORD} +``` + +Finally we can create the region itself. +For additional configuration options for individual OpenStack services, consult `kubectl explain regions.unikorn-cloud.org` for documentation. + +```yaml +apiVersion: unikorn-cloud.org/v1alpha1 +kind: Region +metadata: + name: uk-north-1 +spec: + provider: openstack + openstack: + endpoint: https://openstack.uk-north-1.unikorn-cloud.org:5000 + serviceAccountSecret: + namespace: unikorn + name: uk-north-1-credentials +``` + +Cleanup actions. + +```bash +unset DOMAIN_ID +unset USER_ID +unset PASSWORD +unset DOMAIN +unset USER +``` diff --git a/pkg/providers/openstack/blockstorage.go b/pkg/providers/openstack/blockstorage.go new file mode 100644 index 0000000..f4bb8c6 --- /dev/null +++ b/pkg/providers/openstack/blockstorage.go @@ -0,0 +1,86 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package openstack + +import ( + "context" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack" + "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/availabilityzones" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" + + "github.com/unikorn-cloud/region/pkg/constants" +) + +// BlockStorageClient wraps the generic client because gophercloud is unsafe. +type BlockStorageClient struct { + client *gophercloud.ServiceClient +} + +// NewBlockStorageClient provides a simple one-liner to start computing. +func NewBlockStorageClient(ctx context.Context, provider CredentialProvider) (*BlockStorageClient, error) { + providerClient, err := provider.Client(ctx) + if err != nil { + return nil, err + } + + client, err := openstack.NewBlockStorageV3(providerClient, gophercloud.EndpointOpts{}) + if err != nil { + return nil, err + } + + c := &BlockStorageClient{ + client: client, + } + + return c, nil +} + +// AvailabilityZones retrieves block storage availability zones. +func (c *BlockStorageClient) AvailabilityZones(ctx context.Context) ([]availabilityzones.AvailabilityZone, error) { + url := c.client.ServiceURL("os-availability-zone") + + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, url, trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + pages, err := availabilityzones.List(c.client).AllPages(ctx) + if err != nil { + return nil, err + } + + result, err := availabilityzones.ExtractAvailabilityZones(pages) + if err != nil { + return nil, err + } + + filtered := []availabilityzones.AvailabilityZone{} + + for _, az := range result { + if !az.ZoneState.Available { + continue + } + + filtered = append(filtered, az) + } + + return filtered, nil +} diff --git a/pkg/providers/openstack/client.go b/pkg/providers/openstack/client.go new file mode 100644 index 0000000..625ba60 --- /dev/null +++ b/pkg/providers/openstack/client.go @@ -0,0 +1,207 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package openstack + +import ( + "context" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack" +) + +// authenticatedClient returns a provider client used to initialize service clients. +func authenticatedClient(ctx context.Context, options gophercloud.AuthOptions) (*gophercloud.ProviderClient, error) { + // TODO: the JWT token issuer will cap the expiry at that of the + // keystone token, so we shouldn't get an unauthorized error. Just + // as well as we cannot disambiguate from what gophercloud returns. + client, err := openstack.AuthenticatedClient(ctx, options) + if err != nil { + return nil, err + } + + return client, nil +} + +// CredentialProvider abstracts authentication methods. +type CredentialProvider interface { + // Client returns a new provider client. + Client(ctx context.Context) (*gophercloud.ProviderClient, error) +} + +// ApplicationCredentialProvider allows use of an application credential. +type ApplicationCredentialProvider struct { + endpoint string + id string + secret string +} + +// Ensure the interface is implemented. +var _ CredentialProvider = &ApplicationCredentialProvider{} + +// NewApplicationCredentialProvider creates a client that comsumes application +// credentials for authentication. +func NewApplicationCredentialProvider(endpoint, id, secret string) *ApplicationCredentialProvider { + return &ApplicationCredentialProvider{ + endpoint: endpoint, + id: id, + secret: secret, + } +} + +// Client implements the Provider interface. +func (p *ApplicationCredentialProvider) Client(ctx context.Context) (*gophercloud.ProviderClient, error) { + options := gophercloud.AuthOptions{ + IdentityEndpoint: p.endpoint, + ApplicationCredentialID: p.id, + ApplicationCredentialSecret: p.secret, + AllowReauth: true, + } + + return authenticatedClient(ctx, options) +} + +// PasswordProvider allows use of an application credential. +type PasswordProvider struct { + endpoint string + userID string + password string + projectID string +} + +// Ensure the interface is implemented. +var _ CredentialProvider = &PasswordProvider{} + +// NewPasswordProvider creates a client that comsumes passwords +// for authentication. +func NewPasswordProvider(endpoint, userID, password, projectID string) *PasswordProvider { + return &PasswordProvider{ + endpoint: endpoint, + userID: userID, + password: password, + projectID: projectID, + } +} + +// Client implements the Provider interface. +func (p *PasswordProvider) Client(ctx context.Context) (*gophercloud.ProviderClient, error) { + options := gophercloud.AuthOptions{ + IdentityEndpoint: p.endpoint, + UserID: p.userID, + Password: p.password, + TenantID: p.projectID, + AllowReauth: true, + } + + return authenticatedClient(ctx, options) +} + +// DomainScopedPasswordProvider allows use of an application credential. +type DomainScopedPasswordProvider struct { + endpoint string + userID string + password string + domainID string +} + +// Ensure the interface is implemented. +var _ CredentialProvider = &DomainScopedPasswordProvider{} + +// NewDomainScopedPasswordProvider creates a client that comsumes passwords +// for authentication. +func NewDomainScopedPasswordProvider(endpoint, userID, password, domainID string) *DomainScopedPasswordProvider { + return &DomainScopedPasswordProvider{ + endpoint: endpoint, + userID: userID, + password: password, + domainID: domainID, + } +} + +// Client implements the Provider interface. +func (p *DomainScopedPasswordProvider) Client(ctx context.Context) (*gophercloud.ProviderClient, error) { + options := gophercloud.AuthOptions{ + IdentityEndpoint: p.endpoint, + UserID: p.userID, + Password: p.password, + Scope: &gophercloud.AuthScope{ + DomainID: p.domainID, + }, + AllowReauth: true, + } + + return authenticatedClient(ctx, options) +} + +// TokenProvider creates a client from an endpoint and token. +type TokenProvider struct { + // endpoint is the Keystone endpoint to hit to get access to tokens + // and the service catalog. + endpoint string + + // token is an Openstack authorization token. + token string +} + +// Ensure the interface is implemented. +var _ CredentialProvider = &TokenProvider{} + +// NewTokenProvider returns a new initialized provider. +func NewTokenProvider(endpoint, token string) *TokenProvider { + return &TokenProvider{ + endpoint: endpoint, + token: token, + } +} + +// Client implements the Provider interface. +func (p *TokenProvider) Client(ctx context.Context) (*gophercloud.ProviderClient, error) { + options := gophercloud.AuthOptions{ + IdentityEndpoint: p.endpoint, + TokenID: p.token, + AllowReauth: true, + } + + return authenticatedClient(ctx, options) +} + +// UnauthenticatedProvider is used for token issue. +type UnauthenticatedProvider struct { + // endpoint is the Keystone endpoint to hit to get access to tokens + // and the service catalog. + endpoint string +} + +// Ensure the interface is implemented. +var _ CredentialProvider = &UnauthenticatedProvider{} + +// NewTokenProvider returns a new initialized provider. +func NewUnauthenticatedProvider(endpoint string) *UnauthenticatedProvider { + return &UnauthenticatedProvider{ + endpoint: endpoint, + } +} + +// Client implements the Provider interface. +func (p *UnauthenticatedProvider) Client(ctx context.Context) (*gophercloud.ProviderClient, error) { + client, err := openstack.NewClient(p.endpoint) + if err != nil { + return nil, err + } + + return client, nil +} diff --git a/pkg/providers/openstack/compute.go b/pkg/providers/openstack/compute.go new file mode 100644 index 0000000..db91ab4 --- /dev/null +++ b/pkg/providers/openstack/compute.go @@ -0,0 +1,291 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package openstack + +import ( + "context" + "errors" + "fmt" + "regexp" + "slices" + "strconv" + "time" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/availabilityzones" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/flavors" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/keypairs" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servergroups" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" + + "github.com/unikorn-cloud/core/pkg/util/cache" + unikornv1 "github.com/unikorn-cloud/region/pkg/apis/unikorn/v1alpha1" + "github.com/unikorn-cloud/region/pkg/constants" +) + +var ( + // ErrExpression is raised at runtime when expression evaluation fails. + ErrExpression = errors.New("expression must contain exactly one sub match that yields a number string") +) + +// ComputeClient wraps the generic client because gophercloud is unsafe. +type ComputeClient struct { + options *unikornv1.RegionOpenstackComputeSpec + client *gophercloud.ServiceClient + + flavorCache *cache.TimeoutCache[[]flavors.Flavor] +} + +// NewComputeClient provides a simple one-liner to start computing. +func NewComputeClient(ctx context.Context, provider CredentialProvider, options *unikornv1.RegionOpenstackComputeSpec) (*ComputeClient, error) { + providerClient, err := provider.Client(ctx) + if err != nil { + return nil, err + } + + client, err := openstack.NewComputeV2(providerClient, gophercloud.EndpointOpts{}) + if err != nil { + return nil, err + } + + // Need at least 2.15 for soft-anti-affinity policy. + // Need at least 2.64 for new server group interface. + client.Microversion = "2.90" + + c := &ComputeClient{ + options: options, + client: client, + flavorCache: cache.New[[]flavors.Flavor](time.Hour), + } + + return c, nil +} + +// KeyPairs returns a list of key pairs. +func (c *ComputeClient) KeyPairs(ctx context.Context) ([]keypairs.KeyPair, error) { + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/compute/v2/os-keypairs", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + page, err := keypairs.List(c.client, &keypairs.ListOpts{}).AllPages(ctx) + if err != nil { + return nil, err + } + + return keypairs.ExtractKeyPairs(page) +} + +// Flavors returns a list of flavors. +// +//nolint:cyclop +func (c *ComputeClient) Flavors(ctx context.Context) ([]flavors.Flavor, error) { + if result, ok := c.flavorCache.Get(); ok { + return result, nil + } + + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/compute/v2/flavors", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + page, err := flavors.ListDetail(c.client, &flavors.ListOpts{SortKey: "name"}).AllPages(ctx) + if err != nil { + return nil, err + } + + result, err := flavors.ExtractFlavors(page) + if err != nil { + return nil, err + } + + result = slices.DeleteFunc(result, func(flavor flavors.Flavor) bool { + // We are admin, so see all the things, throw out private flavors. + // TODO: we _could_ allow if our project is in the allowed IDs. + if !flavor.IsPublic { + return true + } + + // Kubeadm requires 2 VCPU, 2 "GB" of RAM (I'll pretend it's GiB) and no swap: + // https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/install-kubeadm/ + if flavor.VCPUs < 2 { + return true + } + + // In MB... + if flavor.RAM < 2048 { + return true + } + + if flavor.Swap != 0 { + return true + } + + if c.options == nil { + return false + } + + for _, exclude := range c.options.FlavorExtraSpecsExclude { + if _, ok := flavor.ExtraSpecs[exclude]; ok { + return true + } + } + + return false + }) + + c.flavorCache.Set(result) + + return result, nil +} + +// GPUMeta describes GPUs. +type GPUMeta struct { + // GPUs is the number of GPUs, this may be the total number + // or physical GPUs, or a single virtual GPU. This value + // is what will be reported for Kubernetes scheduling. + GPUs int +} + +// extraSpecToGPUs evaluates the falvor extra spec and tries to derive +// the number of GPUs, returns -1 if none are found. +func (c *ComputeClient) extraSpecToGPUs(name, value string) (int, error) { + if c.options == nil { + return -1, nil + } + + for _, desc := range c.options.GPUDescriptors { + if desc.Property != name { + continue + } + + re, err := regexp.Compile(desc.Expression) + if err != nil { + return -1, err + } + + matches := re.FindStringSubmatch(value) + if matches == nil { + continue + } + + if len(matches) != 2 { + return -1, ErrExpression + } + + i, err := strconv.Atoi(matches[1]) + if err != nil { + return -1, fmt.Errorf("%w: %s", ErrExpression, err.Error()) + } + + return i, nil + } + + return -1, nil +} + +// FlavorGPUs returns metadata about GPUs, e.g. the number of GPUs. Sadly there is absolutely +// no way of assiging metadata to flavors without having to add those same values to your host +// aggregates, so we have to have knowledge of flavors built in somewhere. +func (c *ComputeClient) FlavorGPUs(flavor *flavors.Flavor) (GPUMeta, error) { + var result GPUMeta + + for name, value := range flavor.ExtraSpecs { + gpus, err := c.extraSpecToGPUs(name, value) + if err != nil { + return result, err + } + + if gpus == -1 { + continue + } + + result.GPUs = gpus + + break + } + + return result, nil +} + +// AvailabilityZones returns a list of availability zones. +func (c *ComputeClient) AvailabilityZones(ctx context.Context) ([]availabilityzones.AvailabilityZone, error) { + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/compute/v2/os-availability-zones", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + page, err := availabilityzones.List(c.client).AllPages(ctx) + if err != nil { + return nil, err + } + + result, err := availabilityzones.ExtractAvailabilityZones(page) + if err != nil { + return nil, err + } + + filtered := []availabilityzones.AvailabilityZone{} + + for _, az := range result { + if !az.ZoneState.Available { + continue + } + + filtered = append(filtered, az) + } + + return filtered, nil +} + +// ListServerGroups returns all server groups in the project. +func (c *ComputeClient) ListServerGroups(ctx context.Context) ([]servergroups.ServerGroup, error) { + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/compute/v2/os-server-groups", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + page, err := servergroups.List(c.client, &servergroups.ListOpts{}).AllPages(ctx) + if err != nil { + return nil, err + } + + return servergroups.ExtractServerGroups(page) +} + +// CreateServerGroup creates the named server group with the given policy and returns +// the result. +func (c *ComputeClient) CreateServerGroup(ctx context.Context, name string) (*servergroups.ServerGroup, error) { + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/compute/v2/os-server-groups", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + opts := &servergroups.CreateOpts{ + Name: name, + Policy: "soft-anti-affinity", + } + + if c.options != nil && c.options.ServerGroupPolicy != nil { + opts.Policy = *c.options.ServerGroupPolicy + } + + return servergroups.Create(ctx, c.client, opts).Extract() +} diff --git a/pkg/providers/openstack/errors.go b/pkg/providers/openstack/errors.go new file mode 100644 index 0000000..0d294f5 --- /dev/null +++ b/pkg/providers/openstack/errors.go @@ -0,0 +1,28 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package openstack + +import ( + "errors" +) + +var ( + // ErrResourceNotFound is returned when a named resource cannot + // be looked up (we have to do it ourselves) and it cannot be found. + ErrResourceNotFound = errors.New("requested resource not found") +) diff --git a/pkg/providers/openstack/identity.go b/pkg/providers/openstack/identity.go new file mode 100644 index 0000000..0bf8505 --- /dev/null +++ b/pkg/providers/openstack/identity.go @@ -0,0 +1,348 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package openstack + +import ( + "context" + "time" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/applicationcredentials" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/projects" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/roles" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/tokens" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/users" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" + + "github.com/unikorn-cloud/core/pkg/util/cache" + "github.com/unikorn-cloud/region/pkg/constants" +) + +// IdentityClient wraps up gophercloud identity management. +type IdentityClient struct { + client *gophercloud.ServiceClient + + roleCache *cache.TimeoutCache[[]roles.Role] +} + +// NewIdentityClient returns a new identity client. +func NewIdentityClient(ctx context.Context, provider CredentialProvider) (*IdentityClient, error) { + providerClient, err := provider.Client(ctx) + if err != nil { + return nil, err + } + + identity, err := openstack.NewIdentityV3(providerClient, gophercloud.EndpointOpts{}) + if err != nil { + return nil, err + } + + client := &IdentityClient{ + client: identity, + roleCache: cache.New[[]roles.Role](time.Hour), + } + + return client, nil +} + +// CreateTokenOptions abstracts away how schizophrenic Openstack is +// with its million options and million ways to fuck it up. +type CreateTokenOptions interface { + // Options returns a valid set of authentication options. + Options() *tokens.AuthOptions +} + +// CreateTokenOptionsUnscopedPassword is typically used when logging on to a UI +// when you don't know anything other than username/password. +type CreateTokenOptionsUnscopedPassword struct { + // domain a user belongs to. + domain string + + // username of the user. + username string + + // password of the user. + password string +} + +// Ensure the CreateTokenOptions interface is implemented. +var _ CreateTokenOptions = &CreateTokenOptionsUnscopedPassword{} + +// NewCreateTokenOptionsUnscopedPassword returns a new instance of unscoped username/password options. +func NewCreateTokenOptionsUnscopedPassword(domain, username, password string) *CreateTokenOptionsUnscopedPassword { + return &CreateTokenOptionsUnscopedPassword{ + domain: domain, + username: username, + password: password, + } +} + +// Options implements the CreateTokenOptions interface. +func (o *CreateTokenOptionsUnscopedPassword) Options() *tokens.AuthOptions { + return &tokens.AuthOptions{ + DomainName: o.domain, + Username: o.username, + Password: o.password, + } +} + +// CreateTokenOptionsScopedToken is typically used to upgrade from an unscoped +// password passed login to a project scoped one once you have determined +// a valid project. +type CreateTokenOptionsScopedToken struct { + // token is the authentication token, it's already scoped to a user and + // domain. + token string + + // projectID is the project ID. We expect an ID because the name/description + // is returned to the user for context, however the ID being passed back in + // defines both the domain and project name, so is simpler and less error + // prone. + projectID string +} + +// Ensure the CreateTokenOptions interface is implemented. +var _ CreateTokenOptions = &CreateTokenOptionsScopedToken{} + +// NewCreateTokenOptionsScopedToken returns a new instance of project scoped token options. +func NewCreateTokenOptionsScopedToken(token, projectID string) *CreateTokenOptionsScopedToken { + return &CreateTokenOptionsScopedToken{ + token: token, + projectID: projectID, + } +} + +// Options implements the CreateTokenOptions interface. +func (o *CreateTokenOptionsScopedToken) Options() *tokens.AuthOptions { + return &tokens.AuthOptions{ + TokenID: o.token, + Scope: tokens.Scope{ + ProjectID: o.projectID, + }, + } +} + +// CreateToken issues a new token. +func (c *IdentityClient) CreateToken(ctx context.Context, options CreateTokenOptions) (*tokens.Token, *tokens.User, error) { + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/identity/v3/auth/tokens", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + result := tokens.Create(ctx, c.client, options.Options()) + + token, err := result.ExtractToken() + if err != nil { + return nil, nil, err + } + + user, err := result.ExtractUser() + if err != nil { + return nil, nil, err + } + + return token, user, nil +} + +// CreateProject creates the named project. +func (c *IdentityClient) CreateProject(ctx context.Context, domainID, name string, tags []string) (*projects.Project, error) { + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/identity/v3/auth/projects", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + // TODO: pass ID in from configuration. + opts := &projects.CreateOpts{ + DomainID: domainID, + Name: name, + Tags: tags, + } + + return projects.Create(ctx, c.client, opts).Extract() +} + +func (c *IdentityClient) DeleteProject(ctx context.Context, projectID string) error { + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/identity/v3/auth/projects/"+projectID, trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + return projects.Delete(ctx, c.client, projectID).Err +} + +// ListAvailableProjects lists projects that an authenticated (but unscoped) user can +// scope to. +func (c *IdentityClient) ListAvailableProjects(ctx context.Context) ([]projects.Project, error) { + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/identity/v3/auth/projects", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + page, err := projects.ListAvailable(c.client).AllPages(ctx) + if err != nil { + return nil, err + } + + items, err := projects.ExtractProjects(page) + if err != nil { + return nil, err + } + + return items, nil +} + +// ListRoles grabs a set of roles that are on the provider. +func (c *IdentityClient) ListRoles(ctx context.Context) ([]roles.Role, error) { + if result, ok := c.roleCache.Get(); ok { + return result, nil + } + + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/identity/v3/auth/roles", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + page, err := roles.List(c.client, &roles.ListOpts{}).AllPages(ctx) + if err != nil { + return nil, err + } + + items, err := roles.ExtractRoles(page) + if err != nil { + return nil, err + } + + c.roleCache.Set(items) + + return items, nil +} + +// CreateRoleAssignment creates a role between a user and a project. +func (c *IdentityClient) CreateRoleAssignment(ctx context.Context, userID, projectID, roleID string) error { + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/identity/v3/auth/role_assignments", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + opts := roles.AssignOpts{ + UserID: userID, + ProjectID: projectID, + } + + err := roles.Assign(ctx, c.client, roleID, opts).ExtractErr() + if err != nil { + return err + } + + return nil +} + +// ListApplicationCredentials lists application credentials for the scoped user. +func (c *IdentityClient) ListApplicationCredentials(ctx context.Context, userID string) ([]applicationcredentials.ApplicationCredential, error) { + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/identity/v3/users/"+userID+"/application_credentials", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + page, err := applicationcredentials.List(c.client, userID, nil).AllPages(ctx) + if err != nil { + return nil, err + } + + items, err := applicationcredentials.ExtractApplicationCredentials(page) + if err != nil { + return nil, err + } + + return items, nil +} + +// CreateApplicationCredential creates an application credential for the user. +func (c *IdentityClient) CreateApplicationCredential(ctx context.Context, userID, name, description string, roles []string) (*applicationcredentials.ApplicationCredential, error) { + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/identity/v3/users/"+userID+"/application_credentials", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + applicationRoles := make([]applicationcredentials.Role, len(roles)) + + for i, role := range roles { + applicationRoles[i].Name = role + } + + opts := &applicationcredentials.CreateOpts{ + Name: name, + Description: description, + Roles: applicationRoles, + } + + result, err := applicationcredentials.Create(ctx, c.client, userID, opts).Extract() + if err != nil { + return nil, err + } + + return result, err +} + +// DeleteApplicationCredential deletes an application credential for the user. +func (c *IdentityClient) DeleteApplicationCredential(ctx context.Context, userID, id string) error { + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/identity/v3/users/"+userID+"/application_credentials/"+id, trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + return applicationcredentials.Delete(ctx, c.client, userID, id).ExtractErr() +} + +// CreateUser creates a new user. +func (c *IdentityClient) CreateUser(ctx context.Context, domainID, name, password string) (*users.User, error) { + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/identity/v3/users", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + opts := &users.CreateOpts{ + Name: name, + DomainID: domainID, + Password: password, + } + + return users.Create(ctx, c.client, opts).Extract() +} + +// GetUser returns user details. +func (c *IdentityClient) GetUser(ctx context.Context, userID string) (*users.User, error) { + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/identity/v3/users/"+userID, trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + return users.Get(ctx, c.client, userID).Extract() +} + +// DeleteUser removes an existing user. +func (c *IdentityClient) DeleteUser(ctx context.Context, userID string) error { + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/identity/v3/users/"+userID, trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + return users.Delete(ctx, c.client, userID).Err +} diff --git a/pkg/providers/openstack/image.go b/pkg/providers/openstack/image.go new file mode 100644 index 0000000..71e8a9e --- /dev/null +++ b/pkg/providers/openstack/image.go @@ -0,0 +1,207 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package openstack + +import ( + "context" + "crypto/ecdsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "slices" + "time" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" + + "github.com/unikorn-cloud/core/pkg/util" + "github.com/unikorn-cloud/core/pkg/util/cache" + unikornv1 "github.com/unikorn-cloud/region/pkg/apis/unikorn/v1alpha1" + "github.com/unikorn-cloud/region/pkg/constants" +) + +var ( + // ErrPEMDecode is raised when the PEM decode failed for some reason. + ErrPEMDecode = errors.New("PEM decode error") + + // ErrPEMType is raised when the encounter the wrong PEM type, e.g. PKCS#1. + ErrPEMType = errors.New("PEM type unsupported") + + // ErrKeyType is raised when we encounter an unsupported key type. + ErrKeyType = errors.New("key type unsupported") +) + +// ImageClient wraps the generic client because gophercloud is unsafe. +type ImageClient struct { + client *gophercloud.ServiceClient + options *unikornv1.RegionOpenstackImageSpec + imageCache *cache.TimeoutCache[[]images.Image] +} + +// NewImageClient provides a simple one-liner to start computing. +func NewImageClient(ctx context.Context, provider CredentialProvider, options *unikornv1.RegionOpenstackImageSpec) (*ImageClient, error) { + providerClient, err := provider.Client(ctx) + if err != nil { + return nil, err + } + + client, err := openstack.NewImageV2(providerClient, gophercloud.EndpointOpts{}) + if err != nil { + return nil, err + } + + c := &ImageClient{ + client: client, + options: options, + imageCache: cache.New[[]images.Image](time.Hour), + } + + return c, nil +} + +func (c *ImageClient) validateProperties(image *images.Image) bool { + if c.options == nil { + return true + } + + for _, r := range c.options.PropertiesInclude { + if !slices.Contains(util.Keys(image.Properties), r) { + return false + } + } + + return true +} + +func (c *ImageClient) decodeSigningKey() (*ecdsa.PublicKey, error) { + pemBlock, _ := pem.Decode(c.options.SigningKey) + if pemBlock == nil { + return nil, ErrPEMDecode + } + + if pemBlock.Type != "PUBLIC KEY" { + return nil, fmt.Errorf("%w: %s", ErrPEMType, pemBlock.Type) + } + + key, err := x509.ParsePKIXPublicKey(pemBlock.Bytes) + if err != nil { + return nil, err + } + + ecKey, ok := key.(*ecdsa.PublicKey) + if !ok { + return nil, ErrKeyType + } + + return ecKey, nil +} + +// verifyImage asserts the image is trustworthy for use with our goodselves. +func (c *ImageClient) verifyImage(image *images.Image) bool { + if c.options == nil || c.options.SigningKey == nil { + return true + } + + if image.Properties == nil { + return false + } + + // These will be digitally signed by Baski when created, so we only trust + // those images. + signatureRaw, ok := image.Properties["digest"] + if !ok { + return false + } + + signatureB64, ok := signatureRaw.(string) + if !ok { + return false + } + + signature, err := base64.StdEncoding.DecodeString(signatureB64) + if err != nil { + return false + } + + hash := sha256.Sum256([]byte(image.ID)) + + signingKey, err := c.decodeSigningKey() + if err != nil { + return false + } + + return ecdsa.VerifyASN1(signingKey, hash[:], signature) +} + +func (c *ImageClient) imageValid(image *images.Image) bool { + if image.Status != "active" { + return false + } + + if !c.validateProperties(image) { + return false + } + + if !c.verifyImage(image) { + return false + } + + return true +} + +// Images returns a list of images. +func (c *ImageClient) Images(ctx context.Context) ([]images.Image, error) { + if result, ok := c.imageCache.Get(); ok { + return result, nil + } + + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/imageservice/v2/images", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + page, err := images.List(c.client, &images.ListOpts{}).AllPages(ctx) + if err != nil { + return nil, err + } + + result, err := images.ExtractImages(page) + if err != nil { + return nil, err + } + + // Filter out images that aren't compatible. + result = slices.DeleteFunc(result, func(image images.Image) bool { + return !c.imageValid(&image) + }) + + // Sort by age, the newest should have the fewest CVEs! + slices.SortStableFunc(result, func(a, b images.Image) int { + return a.CreatedAt.Compare(b.CreatedAt) + }) + + c.imageCache.Set(result) + + return result, nil +} diff --git a/pkg/providers/openstack/network.go b/pkg/providers/openstack/network.go new file mode 100644 index 0000000..b02005b --- /dev/null +++ b/pkg/providers/openstack/network.go @@ -0,0 +1,104 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package openstack + +import ( + "context" + "fmt" + "time" + + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/external" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/networks" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" + + "github.com/unikorn-cloud/core/pkg/util/cache" + "github.com/unikorn-cloud/region/pkg/constants" +) + +// NetworkClient wraps the generic client because gophercloud is unsafe. +type NetworkClient struct { + client *gophercloud.ServiceClient + + externalNetworkCache *cache.TimeoutCache[[]networks.Network] +} + +// NewNetworkClient provides a simple one-liner to start networking. +func NewNetworkClient(ctx context.Context, provider CredentialProvider) (*NetworkClient, error) { + providerClient, err := provider.Client(ctx) + if err != nil { + return nil, err + } + + client, err := openstack.NewNetworkV2(providerClient, gophercloud.EndpointOpts{}) + if err != nil { + return nil, err + } + + c := &NetworkClient{ + client: client, + externalNetworkCache: cache.New[[]networks.Network](time.Hour), + } + + return c, nil +} + +// ExternalNetworks returns a list of external networks. +func (c *NetworkClient) ExternalNetworks(ctx context.Context) ([]networks.Network, error) { + if result, ok := c.externalNetworkCache.Get(); ok { + return result, nil + } + + tracer := otel.GetTracerProvider().Tracer(constants.Application) + + _, span := tracer.Start(ctx, "/networking/v2.0/networks", trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + + affirmative := true + + page, err := networks.List(c.client, &external.ListOptsExt{ListOptsBuilder: &networks.ListOpts{}, External: &affirmative}).AllPages(ctx) + if err != nil { + return nil, err + } + + var results []networks.Network + + if err := networks.ExtractNetworksInto(page, &results); err != nil { + return nil, err + } + + c.externalNetworkCache.Set(results) + + return results, nil +} + +// Get a network for external connectivity. +func (c *NetworkClient) defaultExternalNetwork(ctx context.Context) (*networks.Network, error) { + externalNetworks, err := c.ExternalNetworks(ctx) + if err != nil { + return nil, err + } + + if len(externalNetworks) == 0 { + return nil, fmt.Errorf("%w: default external network", ErrResourceNotFound) + } + + return &externalNetworks[0], nil +} diff --git a/pkg/providers/openstack/provider.go b/pkg/providers/openstack/provider.go new file mode 100644 index 0000000..2e22270 --- /dev/null +++ b/pkg/providers/openstack/provider.go @@ -0,0 +1,556 @@ +/* +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package openstack + +import ( + "context" + "errors" + "fmt" + "reflect" + "sync" + + "github.com/google/uuid" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/applicationcredentials" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/projects" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/roles" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/users" + "github.com/gophercloud/utils/openstack/clientconfig" + + unikornv1 "github.com/unikorn-cloud/region/pkg/apis/unikorn/v1alpha1" + "github.com/unikorn-cloud/region/pkg/providers" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/util/rand" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" +) + +var ( + ErrKeyUndefined = errors.New("a required key was not defined") +) + +type Provider struct { + // client is Kubernetes client. + client client.Client + + // region is the current region configuration. + region *unikornv1.Region + + // secret is the current region secret. + secret *corev1.Secret + + domainID string + userID string + password string + + // DO NOT USE DIRECTLY, CALL AN ACCESSOR. + _identity *IdentityClient + _compute *ComputeClient + _image *ImageClient + _network *NetworkClient + + lock sync.Mutex +} + +var _ providers.Provider = &Provider{} + +func New(client client.Client, region *unikornv1.Region) *Provider { + return &Provider{ + client: client, + region: region, + } +} + +// serviceClientRefresh updates clients if they need to e.g. in the event +// of a configuration update. +// NOTE: you MUST get the lock before calling this function. +// +//nolint:cyclop +func (p *Provider) serviceClientRefresh(ctx context.Context) error { + refresh := false + + region := &unikornv1.Region{} + + if err := p.client.Get(ctx, client.ObjectKey{Name: p.region.Name}, region); err != nil { + return err + } + + // If anything changes with the configuration, referesh the clients as they may + // do caching. + if !reflect.DeepEqual(region.Spec.Openstack, p.region.Spec.Openstack) { + refresh = true + } + + secretkey := client.ObjectKey{ + Namespace: region.Spec.Openstack.ServiceAccountSecret.Namespace, + Name: region.Spec.Openstack.ServiceAccountSecret.Name, + } + + secret := &corev1.Secret{} + + if err := p.client.Get(ctx, secretkey, secret); err != nil { + return err + } + + // If the secret hasn't beed read yet, or has changed e.g. credential rotation + // then refresh the clients as they cache the API token. + if p.secret == nil || !reflect.DeepEqual(secret.Data, p.secret.Data) { + refresh = true + } + + // Nothing to do, use what's there. + if !refresh { + return nil + } + + // Create the core credential provider. + domainID, ok := secret.Data["domain-id"] + if !ok { + return fmt.Errorf("%w: domain-id", ErrKeyUndefined) + } + + userID, ok := secret.Data["user-id"] + if !ok { + return fmt.Errorf("%w: user-id", ErrKeyUndefined) + } + + password, ok := secret.Data["password"] + if !ok { + return fmt.Errorf("%w: password", ErrKeyUndefined) + } + + // Pass in an empty string to use the default project. + providerClient := NewDomainScopedPasswordProvider(region.Spec.Openstack.Endpoint, string(userID), string(password), string(domainID)) + + // Create the clients. + identity, err := NewIdentityClient(ctx, providerClient) + if err != nil { + return err + } + + compute, err := NewComputeClient(ctx, providerClient, region.Spec.Openstack.Compute) + if err != nil { + return err + } + + image, err := NewImageClient(ctx, providerClient, region.Spec.Openstack.Image) + if err != nil { + return err + } + + network, err := NewNetworkClient(ctx, providerClient) + if err != nil { + return err + } + + // Save the current configuration for checking next time. + p.region = region + p.secret = secret + + p.domainID = string(domainID) + p.userID = string(userID) + p.password = string(password) + + // Seve the clients + p._identity = identity + p._compute = compute + p._image = image + p._network = network + + return nil +} + +func (p *Provider) identity(ctx context.Context) (*IdentityClient, error) { + p.lock.Lock() + defer p.lock.Unlock() + + if err := p.serviceClientRefresh(ctx); err != nil { + return nil, err + } + + return p._identity, nil +} + +func (p *Provider) compute(ctx context.Context) (*ComputeClient, error) { + p.lock.Lock() + defer p.lock.Unlock() + + if err := p.serviceClientRefresh(ctx); err != nil { + return nil, err + } + + return p._compute, nil +} + +func (p *Provider) image(ctx context.Context) (*ImageClient, error) { + p.lock.Lock() + defer p.lock.Unlock() + + if err := p.serviceClientRefresh(ctx); err != nil { + return nil, err + } + + return p._image, nil +} + +func (p *Provider) network(ctx context.Context) (*NetworkClient, error) { + p.lock.Lock() + defer p.lock.Unlock() + + if err := p.serviceClientRefresh(ctx); err != nil { + return nil, err + } + + return p._network, nil +} + +// Flavors list all available flavors. +func (p *Provider) Flavors(ctx context.Context) (providers.FlavorList, error) { + computeService, err := p.compute(ctx) + if err != nil { + return nil, err + } + + resources, err := computeService.Flavors(ctx) + if err != nil { + return nil, err + } + + result := make(providers.FlavorList, 0, len(resources)) + + for i := range resources { + flavor := &resources[i] + + gpus, err := computeService.FlavorGPUs(flavor) + if err != nil { + return nil, err + } + + // API memory is in MiB, disk is in GB + result = append(result, providers.Flavor{ + Name: flavor.Name, + CPUs: flavor.VCPUs, + Memory: resource.NewQuantity(int64(flavor.RAM)<<20, resource.BinarySI), + Disk: resource.NewScaledQuantity(int64(flavor.Disk), resource.Giga), + GPUs: gpus.GPUs, + GPUVendor: providers.Nvidia, + }) + } + + return result, nil +} + +// Images lists all available images. +func (p *Provider) Images(ctx context.Context) (providers.ImageList, error) { + imageService, err := p.image(ctx) + if err != nil { + return nil, err + } + + resources, err := imageService.Images(ctx) + if err != nil { + return nil, err + } + + result := make(providers.ImageList, 0, len(resources)) + + for i := range resources { + image := &resources[i] + + kuebernetesVersion, _ := image.Properties["k8s"].(string) + + result = append(result, providers.Image{ + Name: image.Name, + Created: image.CreatedAt, + Modified: image.UpdatedAt, + KubernetesVersion: kuebernetesVersion, + }) + } + + return result, nil +} + +const ( + // ProjectIDAnnotation records the project ID created for a cluster. + ProjectIDAnnotation = "openstack." + providers.MetdataDomain + "/project-id" + // UserIDAnnotation records the user ID create for a cluster. + UserIDAnnotation = "openstack." + providers.MetdataDomain + "/user-id" + + // Projects are randomly named to avoid clashes, so we need to add some tags + // in order to be able to reason about who they really belong to. It is also + // useful to have these in place so we can spot orphaned resources and garbage + // collect them. + OrganizationTag = "organization" + ProjectTag = "project" + ClusterTag = "cluster" +) + +type ClusterInfo struct { + OrganizationID string + ProjectID string + ClusterID string +} + +// projectTags defines how to tag projects. +func projectTags(info *ClusterInfo) []string { + tags := []string{ + OrganizationTag + "=" + info.OrganizationID, + ProjectTag + "=" + info.ProjectID, + ClusterTag + "=" + info.ClusterID, + } + + return tags +} + +// provisionUser creates a new user in the managed domain with a random password. +// There is a 1:1 mapping of user to project, and the project name is unique in the +// domain, so just reuse this, we can clean them up at the same time. +func (p *Provider) provisionUser(ctx context.Context, identityService *IdentityClient, project *projects.Project) (*users.User, string, error) { + password := uuid.New().String() + + user, err := identityService.CreateUser(ctx, p.domainID, project.Name, password) + if err != nil { + return nil, "", err + } + + return user, password, nil +} + +// provisionProject creates a project per-cluster. Cluster API provider Openstack is +// somewhat broken in that networks can alias and cause all kinds of disasters, so it's +// safest to have one cluster in one project so it has its own namespace. +func (p *Provider) provisionProject(ctx context.Context, identityService *IdentityClient, info *ClusterInfo) (*projects.Project, error) { + name := "unikorn-" + rand.String(8) + + project, err := identityService.CreateProject(ctx, p.domainID, name, projectTags(info)) + if err != nil { + return nil, err + } + + return project, nil +} + +// roleNameToID maps from something human readable to something Openstack will operate with +// because who doesn't like extra, slow, API calls... +func roleNameToID(roles []roles.Role, name string) (string, error) { + for _, role := range roles { + if role.Name == name { + return role.ID, nil + } + } + + return "", fmt.Errorf("%w: role %s", ErrResourceNotFound, name) +} + +// getRequiredRoles returns the roles required for a user to create, manage and delete +// a cluster. +func (p *Provider) getRequiredRoles() []string { + if p.region.Spec.Openstack.Identity != nil && len(p.region.Spec.Openstack.Identity.ClusterRoles) > 0 { + return p.region.Spec.Openstack.Identity.ClusterRoles + } + + // TODO: _member_ shouldn't be necessary, delete me when we get a hsndle on it. + // This is quired by Octavia to list providers and load balancers at the very least. + defaultRoles := []string{ + "_member_", + "member", + "load-balancer_member", + } + + return defaultRoles +} + +// provisionProjectRoles creates a binding between our service account and the project +// with the required roles to provision an application credential that will allow cluster +// creation, deletion and life-cycle management. +func (p *Provider) provisionProjectRoles(ctx context.Context, identityService *IdentityClient, userID string, project *projects.Project) error { + allRoles, err := identityService.ListRoles(ctx) + if err != nil { + return err + } + + for _, name := range p.getRequiredRoles() { + roleID, err := roleNameToID(allRoles, name) + if err != nil { + return err + } + + if err := identityService.CreateRoleAssignment(ctx, userID, project.ID, roleID); err != nil { + return err + } + } + + return nil +} + +func (p *Provider) provisionApplicationCredential(ctx context.Context, userID, password string, project *projects.Project) (*applicationcredentials.ApplicationCredential, error) { + // Rescope to the project... + providerClient := NewPasswordProvider(p.region.Spec.Openstack.Endpoint, userID, password, project.ID) + + projectScopedIdentity, err := NewIdentityClient(ctx, providerClient) + if err != nil { + return nil, err + } + + // Application crdentials are scoped to the user, not the project, so the name needs + // to be unique, so just use the project name. + return projectScopedIdentity.CreateApplicationCredential(ctx, userID, project.Name, "IaaS lifecycle management", p.getRequiredRoles()) +} + +type CloudConfigType string + +const ( + CloudConfigTypeOpenStack CloudConfigType = "openstack" +) + +type OpenStackCloudCredentials struct { + Cloud string + CloudConfig []byte + ExternalNetworkID string +} + +type OpenStackCloudState struct { + UserID string + ProjectID string +} + +type OpenStackCloudConfig struct { + Credentials *OpenStackCloudCredentials + State *OpenStackCloudState +} + +type CloudConfig struct { + Type CloudConfigType + OpenStack *OpenStackCloudConfig +} + +func (p *Provider) createClientConfig(applicationCredential *applicationcredentials.ApplicationCredential) (*OpenStackCloudCredentials, error) { + cloud := "cloud" + + clientConfig := &clientconfig.Clouds{ + Clouds: map[string]clientconfig.Cloud{ + cloud: { + AuthType: clientconfig.AuthV3ApplicationCredential, + AuthInfo: &clientconfig.AuthInfo{ + AuthURL: p.region.Spec.Openstack.Endpoint, + ApplicationCredentialID: applicationCredential.ID, + ApplicationCredentialSecret: applicationCredential.Secret, + }, + }, + }, + } + + clientConfigYAML, err := yaml.Marshal(clientConfig) + if err != nil { + return nil, err + } + + credentials := &OpenStackCloudCredentials{ + Cloud: cloud, + CloudConfig: clientConfigYAML, + } + + return credentials, nil +} + +// ConfigureCluster does any provider specific configuration for a cluster. +// +//nolint:cyclop +func (p *Provider) ConfigureCluster(ctx context.Context, info *ClusterInfo) (*CloudConfig, error) { + identityService, err := p.identity(ctx) + if err != nil { + return nil, err + } + + // Every cluster has its own project to mitigate "nuances" in CAPO i.e. it's + // totally broken when it comes to network aliasing. + project, err := p.provisionProject(ctx, identityService, info) + if err != nil { + return nil, err + } + + // You MUST provision a new user, if we rotate a password, any application credentials + // hanging off it will stop working, i.e. doing that to the unikorn management user + // will be pretty catastrophic for all clusters in the region. + user, password, err := p.provisionUser(ctx, identityService, project) + if err != nil { + return nil, err + } + + // Give the user only what permissions they need to provision a cluster and + // manage it during its lifetime. + if err := p.provisionProjectRoles(ctx, identityService, user.ID, project); err != nil { + return nil, err + } + + // Always use application credentials, they are scoped to a single project and + // cannot be used to break from that jail. + applicationCredential, err := p.provisionApplicationCredential(ctx, user.ID, password, project) + if err != nil { + return nil, err + } + + credentials, err := p.createClientConfig(applicationCredential) + if err != nil { + return nil, err + } + + networkService, err := p.network(ctx) + if err != nil { + return nil, err + } + + externalNetwork, err := networkService.defaultExternalNetwork(ctx) + if err != nil { + return nil, err + } + + config := &CloudConfig{ + Type: CloudConfigTypeOpenStack, + OpenStack: &OpenStackCloudConfig{ + Credentials: credentials, + State: &OpenStackCloudState{ + UserID: user.ID, + ProjectID: project.ID, + }, + }, + } + + config.OpenStack.Credentials.ExternalNetworkID = externalNetwork.ID + + return config, nil +} + +// DeconfigureCluster does any provider specific cluster cleanup. +func (p *Provider) DeconfigureCluster(ctx context.Context, state *OpenStackCloudState) error { + identityService, err := p.identity(ctx) + if err != nil { + return err + } + + if err := identityService.DeleteUser(ctx, state.UserID); err != nil { + return err + } + + if err := identityService.DeleteProject(ctx, state.ProjectID); err != nil { + return err + } + + return nil +} diff --git a/pkg/providers/types.go b/pkg/providers/types.go new file mode 100644 index 0000000..24ca2b2 --- /dev/null +++ b/pkg/providers/types.go @@ -0,0 +1,70 @@ +/* +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package providers + +import ( + "time" + + "k8s.io/apimachinery/pkg/api/resource" +) + +// GPUVendor defines the GPU vendor. +type GPUVendor string + +const ( + Nvidia GPUVendor = "nvidia" + AMD GPUVendor = "amd" +) + +// Flavor represents a machine type. +type Flavor struct { + // Name of the flavor. + Name string + // CPU count. + CPUs int + // Memory available. + Memory *resource.Quantity + // Disk available. + Disk *resource.Quantity + // GPU count. + GPUs int + // GPUVendor is who makes the GPU, used to determine the drivers etc. + GPUVendor GPUVendor + // BareMetal is a bare-metal flavor. + BareMetal bool +} + +// FlavorList allows us to attach sort functions and the like. +type FlavorList []Flavor + +// Image represents an operating system image. +type Image struct { + // Name of the image. + Name string + // Created is when the image was created. + Created time.Time + // Modified is when the image was modified. + Modified time.Time + // KubernetesVersion is only populated if the image contains a pre-installed + // version of Kubernetes, this acts as a cache and improves provisioning performance. + // This is pretty much the only source of truth about Kubernetes versions at + // present, so should be populated. + KubernetesVersion string +} + +// ImageList allows us to attach sort functions and the like. +type ImageList []Image diff --git a/pkg/server/options.go b/pkg/server/options.go new file mode 100644 index 0000000..6cff148 --- /dev/null +++ b/pkg/server/options.go @@ -0,0 +1,64 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package server + +import ( + "time" + + "github.com/spf13/pflag" +) + +// Options allows server options to be overridden. +type Options struct { + // ListenAddress tells the server what to listen on, you shouldn't + // need to change this, its already non-privileged and the default + // should be modified to avoid clashes with other services e.g prometheus. + ListenAddress string + + // ReadTimeout defines how long before we give up on the client, + // this should be fairly short. + ReadTimeout time.Duration + + // ReadHeaderTimeout defines how long before we give up on the client, + // this should be fairly short. + ReadHeaderTimeout time.Duration + + // WriteTimeout defines how long we take to respond before we give up. + // Ideally we'd like this to be short, but Openstack in general sucks + // for performance. Additionally some calls like cluster creation can + // do a cascading create, e.g. create a default control plane, than in + // turn creates a project. + WriteTimeout time.Duration + + // OTLPEndpoint defines whether to ship spans to an OTLP consumer or + // not, and where to send them to. + OTLPEndpoint string + + // RequestTimeout places a hard limit on all requests lengths. + RequestTimeout time.Duration +} + +// addFlags allows server options to be modified. +func (o *Options) AddFlags(f *pflag.FlagSet) { + f.StringVar(&o.ListenAddress, "server-listen-address", ":6080", "API listener address.") + f.DurationVar(&o.ReadTimeout, "server-read-timeout", time.Second, "How long to wait for the client to send the request body.") + f.DurationVar(&o.ReadHeaderTimeout, "server-read-header-timeout", time.Second, "How long to wait for the client to send headers.") + f.DurationVar(&o.WriteTimeout, "server-write-timeout", 10*time.Second, "How long to wait for the API to respond to the client.") + f.DurationVar(&o.RequestTimeout, "server-request-timeout", 30*time.Second, "How long to wait of a request to be serviced.") + f.StringVar(&o.OTLPEndpoint, "otlp-endpoint", "", "An optional OTLP endpoint to ship spans to.") +} diff --git a/pkg/server/server.go b/pkg/server/server.go new file mode 100644 index 0000000..c9d62b3 --- /dev/null +++ b/pkg/server/server.go @@ -0,0 +1,170 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package server + +import ( + "context" + "flag" + "fmt" + "net/http" + "net/http/pprof" + + chi "github.com/go-chi/chi/v5" + "github.com/spf13/pflag" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/trace" + + "github.com/unikorn-cloud/core/pkg/server/middleware/cors" + openapimiddleware "github.com/unikorn-cloud/core/pkg/server/middleware/openapi" + "github.com/unikorn-cloud/core/pkg/server/middleware/openapi/oidc" + "github.com/unikorn-cloud/core/pkg/server/middleware/opentelemetry" + "github.com/unikorn-cloud/core/pkg/server/middleware/timeout" + "github.com/unikorn-cloud/region/pkg/constants" + "github.com/unikorn-cloud/region/pkg/handler" + "github.com/unikorn-cloud/region/pkg/openapi" + + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +type Server struct { + // Options are server specific options e.g. listener address etc. + Options Options + + // ZapOptions configure logging. + ZapOptions zap.Options + + // HandlerOptions sets options for the HTTP handler. + HandlerOptions handler.Options + + // AuthorizerOptions allow configuration of the OIDC backend. + AuthorizerOptions oidc.Options + + // CORSOptions are for remote resource sharing. + CORSOptions cors.Options +} + +func (s *Server) AddFlags(goflags *flag.FlagSet, flags *pflag.FlagSet) { + s.ZapOptions.BindFlags(goflags) + + s.Options.AddFlags(flags) + s.HandlerOptions.AddFlags(flags) + s.AuthorizerOptions.AddFlags(flags) + s.CORSOptions.AddFlags(flags) +} + +func (s *Server) SetupLogging() { + log.SetLogger(zap.New(zap.UseFlagOptions(&s.ZapOptions))) +} + +// SetupOpenTelemetry adds a span processor that will print root spans to the +// logs by default, and optionally ship the spans to an OTLP listener. +// TODO: move config into an otel specific options struct. +func (s *Server) SetupOpenTelemetry(ctx context.Context) error { + otel.SetLogger(log.Log) + + otel.SetTextMapPropagator(propagation.TraceContext{}) + + opts := []trace.TracerProviderOption{ + trace.WithSpanProcessor(&opentelemetry.LoggingSpanProcessor{}), + } + + if s.Options.OTLPEndpoint != "" { + exporter, err := otlptracehttp.New(ctx, + otlptracehttp.WithEndpoint(s.Options.OTLPEndpoint), + otlptracehttp.WithInsecure(), + ) + + if err != nil { + return err + } + + opts = append(opts, trace.WithBatcher(exporter)) + } + + otel.SetTracerProvider(trace.NewTracerProvider(opts...)) + + return nil +} + +func (s *Server) GetServer(client client.Client) (*http.Server, error) { + pprofHandler := http.NewServeMux() + pprofHandler.HandleFunc("/debug/pprof/", pprof.Index) + pprofHandler.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) + pprofHandler.HandleFunc("/debug/pprof/profile", pprof.Profile) + pprofHandler.HandleFunc("/debug/pprof/symbol", pprof.Symbol) + pprofHandler.HandleFunc("/debug/pprof/trace", pprof.Trace) + + go func() { + pprofServer := http.Server{ + Addr: ":6060", + ReadTimeout: s.Options.ReadTimeout, + ReadHeaderTimeout: s.Options.ReadHeaderTimeout, + WriteTimeout: s.Options.WriteTimeout, + Handler: pprofHandler, + } + + if err := pprofServer.ListenAndServe(); err != nil { + fmt.Println(err) + } + }() + + schema, err := openapimiddleware.NewSchema(openapi.GetSwagger) + if err != nil { + return nil, err + } + + // Middleware specified here is applied to all requests pre-routing. + router := chi.NewRouter() + router.Use(timeout.Middleware(s.Options.RequestTimeout)) + router.Use(opentelemetry.Middleware(constants.Application, constants.Version)) + router.Use(cors.Middleware(schema, &s.CORSOptions)) + router.NotFound(http.HandlerFunc(handler.NotFound)) + router.MethodNotAllowed(http.HandlerFunc(handler.MethodNotAllowed)) + + // Setup middleware. + authorizer := oidc.NewAuthorizer(&s.AuthorizerOptions) + + // Middleware specified here is applied to all requests post-routing. + // NOTE: these are applied in reverse order!! + chiServerOptions := openapi.ChiServerOptions{ + BaseRouter: router, + ErrorHandlerFunc: handler.HandleError, + Middlewares: []openapi.MiddlewareFunc{ + openapimiddleware.Middleware(authorizer, schema), + }, + } + + handlerInterface, err := handler.New(client, &s.HandlerOptions, &s.AuthorizerOptions) + if err != nil { + return nil, err + } + + server := &http.Server{ + Addr: s.Options.ListenAddress, + ReadTimeout: s.Options.ReadTimeout, + ReadHeaderTimeout: s.Options.ReadHeaderTimeout, + WriteTimeout: s.Options.WriteTimeout, + Handler: openapi.HandlerWithOptions(handlerInterface, chiServerOptions), + } + + return server, nil +} diff --git a/pkg/server/util/json.go b/pkg/server/util/json.go new file mode 100644 index 0000000..1a58503 --- /dev/null +++ b/pkg/server/util/json.go @@ -0,0 +1,62 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/unikorn-cloud/core/pkg/server/errors" + + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// WriteJSONResponse is a generic wrapper for returning a JSON payload to the client. +func WriteJSONResponse(w http.ResponseWriter, r *http.Request, code int, response interface{}) { + log := log.FromContext(r.Context()) + + body, err := json.Marshal(response) + if err != nil { + log.Error(err, "unable to marshal body") + + return + } + + w.Header().Add("Content-Type", "application/json") + + w.WriteHeader(code) + + if _, err := w.Write(body); err != nil { + log.Error(err, "failed to write response") + } +} + +// ReadJSONBody is a generic request reader to unmarshal JSON bodies. +func ReadJSONBody(r *http.Request, v interface{}) error { + body, err := io.ReadAll(r.Body) + if err != nil { + return errors.OAuth2ServerError("unable to read request body").WithError(err) + } + + if err := json.Unmarshal(body, v); err != nil { + return errors.OAuth2ServerError("unable to unmarshal request body").WithError(err) + } + + return nil +} diff --git a/pkg/server/util/octet_stream.go b/pkg/server/util/octet_stream.go new file mode 100644 index 0000000..df5702e --- /dev/null +++ b/pkg/server/util/octet_stream.go @@ -0,0 +1,37 @@ +/* +Copyright 2022-2024 EscherCloud. +Copyright 2024 the Unikorn Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package util + +import ( + "net/http" + + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// WriteOctetStreamResponse is a generic wrapper for returning a OctetStream payload to the client. +func WriteOctetStreamResponse(w http.ResponseWriter, r *http.Request, code int, body []byte) { + log := log.FromContext(r.Context()) + + w.Header().Add("Content-Type", "application/octet-stream") + + w.WriteHeader(code) + + if _, err := w.Write(body); err != nil { + log.Error(err, "failed to write response") + } +}