diff --git a/Makefile b/Makefile index b0378a8..6471f97 100644 --- a/Makefile +++ b/Makefile @@ -30,9 +30,11 @@ test: fmt vet ## Run tests. test -f ${ENVTEST_ASSETS_DIR}/setup-envtest.sh || curl -sSLo ${ENVTEST_ASSETS_DIR}/setup-envtest.sh https://raw.githubusercontent.com/kubernetes-sigs/controller-runtime/v0.8.3/hack/setup-envtest.sh source ${ENVTEST_ASSETS_DIR}/setup-envtest.sh; fetch_envtest_tools $(ENVTEST_ASSETS_DIR); setup_envtest_env $(ENVTEST_ASSETS_DIR); go test ./... -coverprofile cover.out -shuffle on -test-e2e: +test-e2e: ginkgo +ifndef SKIP_DEPLOY ./scripts/deploy-on-kind.sh e2e - KUBECONFIG="${HOME}/.kube/e2e.yml" API_SERVER_ROOT=http://localhost ROOT_NAMESPACE=cf-k8s-api-system go test -tags e2e -count 1 ./tests/e2e +endif + KUBECONFIG="${HOME}/.kube/e2e.yml" API_SERVER_ROOT=http://localhost ROOT_NAMESPACE=cf-k8s-api-system $(GINKGO) -p -randomizeAllSpecs -randomizeSuites -keepGoing -slowSpecThreshold 30 -tags e2e tests/e2e run: fmt vet ## Run a controller from your host. go run ./main.go @@ -69,15 +71,33 @@ KUSTOMIZE = $(shell pwd)/bin/kustomize kustomize: ## Download kustomize locally if necessary. $(call go-get-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v4@v4.2.0) +GINKGO = $(shell pwd)/bin/ginkgo +ginkgo: + $(call go-get-tool,$(GINKGO),github.com/onsi/ginkgo/ginkgo@latest) + HNC_VERSION ?= v0.8.0 +HNC_PLATFORM=$(shell go env GOHOSTOS)_$(shell go env GOHOSTARCH) +HNC_BIN=$(shell pwd)/bin +export PATH := $(HNC_BIN):$(PATH) hnc-install: + mkdir -p "$(HNC_BIN)" + curl -L https://github.com/kubernetes-sigs/multi-tenancy/releases/download/hnc-$(HNC_VERSION)/kubectl-hns_$(HNC_PLATFORM) -o "$(HNC_BIN)/kubectl-hns" + chmod +x "$(HNC_BIN)/kubectl-hns" + kubectl label ns kube-system hnc.x-k8s.io/excluded-namespace=true --overwrite kubectl label ns kube-public hnc.x-k8s.io/excluded-namespace=true --overwrite kubectl label ns kube-node-lease hnc.x-k8s.io/excluded-namespace=true --overwrite kubectl apply -f https://github.com/kubernetes-sigs/multi-tenancy/releases/download/hnc-$(HNC_VERSION)/hnc-manager.yaml kubectl rollout status deployment/hnc-controller-manager -w -n hnc-system - echo -n waiting for manager to be ready and servicing validating webhooks - until kubectl logs -n hnc-system deployment/hnc-controller-manager manager | grep -q "setup complete"; do echo -n .; sleep 0.5; done + # Hierarchical namespace controller is quite asynchronous. There is no + # guarantee that the operations below would succeed on first invocation, + # so retry until they do. + echo -n waiting for hns controller to be ready and servicing validating webhooks + until kubectl create namespace ping-hnc; do echo -n .; sleep 0.5; done + until kubectl hns create -n ping-hnc ping-hnc-child; do echo -n .; sleep 0.5; done + until kubectl get namespace ping-hnc-child; do echo -n .; sleep 0.5; done + until kubectl hns set --allowCascadingDeletion ping-hnc; do echo -n .; sleep 0.5; done + until kubectl delete namespace ping-hnc --wait=false; do echo -n .; sleep 0.5; done echo # go-get-tool will 'go get' any package $2 and install it to $1. diff --git a/apis/app_handler.go b/apis/app_handler.go index fac0bc3..573e25f 100644 --- a/apis/app_handler.go +++ b/apis/app_handler.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "code.cloudfoundry.org/cf-k8s-controllers/webhooks/workloads" @@ -42,14 +43,20 @@ type CFAppRepository interface { type AppHandler struct { logger logr.Logger - serverURL string + serverURL url.URL appRepo CFAppRepository dropletRepo CFDropletRepository buildClient ClientBuilder k8sConfig *rest.Config // TODO: this would be global for all requests, not what we want } -func NewAppHandler(logger logr.Logger, serverURL string, appRepo CFAppRepository, dropletRepo CFDropletRepository, buildClient ClientBuilder, k8sConfig *rest.Config) *AppHandler { +func NewAppHandler( + logger logr.Logger, + serverURL url.URL, + appRepo CFAppRepository, + dropletRepo CFDropletRepository, + buildClient ClientBuilder, + k8sConfig *rest.Config) *AppHandler { return &AppHandler{ logger: logger, serverURL: serverURL, diff --git a/apis/app_handler_test.go b/apis/app_handler_test.go index c96ad66..59c18a3 100644 --- a/apis/app_handler_test.go +++ b/apis/app_handler_test.go @@ -3,8 +3,10 @@ package apis_test import ( "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" + "net/url" "strings" "github.com/gorilla/mux" @@ -46,10 +48,11 @@ var _ = Describe("AppHandler", func() { rr = httptest.NewRecorder() router = mux.NewRouter() - + serverURL, err := url.Parse(defaultServerURL) + Expect(err).NotTo(HaveOccurred()) apiHandler := NewAppHandler( logf.Log.WithName(testAppHandlerLoggerName), - defaultServerURL, + *serverURL, appRepo, dropletRepo, clientBuilder.Spy, @@ -92,77 +95,76 @@ var _ = Describe("AppHandler", func() { contentTypeHeader := rr.Header().Get("Content-Type") Expect(contentTypeHeader).To(Equal(jsonHeader), "Matching Content-Type header:") - Expect(rr.Body.String()).To(MatchJSON(`{ - "guid": "`+appGUID+`", - "created_at": "", - "updated_at": "", - "name": "test-app", - "state": "STOPPED", - "lifecycle": { - "type": "buildpack", - "data": { - "buildpacks": [], - "stack": "" - } - }, - "relationships": { - "space": { - "data": { - "guid": "`+spaceGUID+`" - } - } - }, - "metadata": { - "labels": {}, - "annotations": {} - }, - "links": { - "self": { - "href": "https://api.example.org/v3/apps/`+appGUID+`" - }, - "environment_variables": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/environment_variables" - }, - "space": { - "href": "https://api.example.org/v3/spaces/`+spaceGUID+`" - }, - "processes": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/processes" - }, - "packages": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/packages" - }, - "current_droplet": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/droplets/current" - }, - "droplets": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/droplets" - }, - "tasks": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/tasks" - }, - "start": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/actions/start", - "method": "POST" - }, - "stop": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/actions/stop", - "method": "POST" - }, - "revisions": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/revisions" - }, - "deployed_revisions": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/revisions/deployed" - }, - "features": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/features" - } - } - }`), "Response body matches response:") + Expect(rr.Body.String()).To(MatchJSON(fmt.Sprintf(`{ + "guid": "%[2]s", + "created_at": "", + "updated_at": "", + "name": "test-app", + "state": "STOPPED", + "lifecycle": { + "type": "buildpack", + "data": { + "buildpacks": [], + "stack": "" + } + }, + "relationships": { + "space": { + "data": { + "guid": "%[3]s" + } + } + }, + "metadata": { + "labels": {}, + "annotations": {} + }, + "links": { + "self": { + "href": "https://api.example.org/v3/apps/%[2]s" + }, + "environment_variables": { + "href": "https://api.example.org/v3/apps/%[2]s/environment_variables" + }, + "space": { + "href": "https://api.example.org/v3/spaces/%[3]s" + }, + "processes": { + "href": "https://api.example.org/v3/apps/%[2]s/processes" + }, + "packages": { + "href": "https://api.example.org/v3/apps/%[2]s/packages" + }, + "current_droplet": { + "href": "https://api.example.org/v3/apps/%[2]s/droplets/current" + }, + "droplets": { + "href": "https://api.example.org/v3/apps/%[2]s/droplets" + }, + "tasks": { + "href": "https://api.example.org/v3/apps/%[2]s/tasks" + }, + "start": { + "href": "https://api.example.org/v3/apps/%[2]s/actions/start", + "method": "POST" + }, + "stop": { + "href": "https://api.example.org/v3/apps/%[2]s/actions/stop", + "method": "POST" + }, + "revisions": { + "href": "https://api.example.org/v3/apps/%[2]s/revisions" + }, + "deployed_revisions": { + "href": "https://api.example.org/v3/apps/%[2]s/revisions/deployed" + }, + "features": { + "href": "https://api.example.org/v3/apps/%[2]s/features" + } + } + }`, defaultServerURL, appGUID, spaceGUID)), "Response body matches response:") }) }) - When("the app cannot be found", func() { BeforeEach(func() { appRepo.FetchAppReturns(repositories.AppRecord{}, repositories.NotFoundError{}) @@ -398,8 +400,8 @@ var _ = Describe("AppHandler", func() { }) It("returns the \"created app\"(the mock response record) in the response", func() { - Expect(rr.Body.String()).To(MatchJSON(`{ - "guid": "`+appGUID+`", + Expect(rr.Body.String()).To(MatchJSON(fmt.Sprintf(`{ + "guid": "%[2]s", "created_at": "", "updated_at": "", "name": "test-app", @@ -414,7 +416,7 @@ var _ = Describe("AppHandler", func() { "relationships": { "space": { "data": { - "guid": "`+spaceGUID+`" + "guid": "%[3]s" } } }, @@ -424,48 +426,48 @@ var _ = Describe("AppHandler", func() { }, "links": { "self": { - "href": "https://api.example.org/v3/apps/`+appGUID+`" + "href": "%[1]s/v3/apps/%[2]s" }, "environment_variables": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/environment_variables" + "href": "%[1]s/v3/apps/%[2]s/environment_variables" }, "space": { - "href": "https://api.example.org/v3/spaces/`+spaceGUID+`" + "href": "%[1]s/v3/spaces/%[3]s" }, "processes": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/processes" + "href": "%[1]s/v3/apps/%[2]s/processes" }, "packages": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/packages" + "href": "%[1]s/v3/apps/%[2]s/packages" }, "current_droplet": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/droplets/current" + "href": "%[1]s/v3/apps/%[2]s/droplets/current" }, "droplets": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/droplets" + "href": "%[1]s/v3/apps/%[2]s/droplets" }, "tasks": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/tasks" + "href": "%[1]s/v3/apps/%[2]s/tasks" }, "start": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/actions/start", + "href": "%[1]s/v3/apps/%[2]s/actions/start", "method": "POST" }, "stop": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/actions/stop", + "href": "%[1]s/v3/apps/%[2]s/actions/stop", "method": "POST" }, "revisions": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/revisions" + "href": "%[1]s/v3/apps/%[2]s/revisions" }, "deployed_revisions": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/revisions/deployed" + "href": "%[1]s/v3/apps/%[2]s/revisions/deployed" }, "features": { - "href": "https://api.example.org/v3/apps/`+appGUID+`/features" + "href": "%[1]s/v3/apps/%[2]s/features" } } - }`), "Response body matches response:") + }`, defaultServerURL, appGUID, spaceGUID)), "Response body matches response:") }) }) @@ -599,15 +601,15 @@ var _ = Describe("AppHandler", func() { }) It("returns the Pagination Data and App Resources in the response", func() { - Expect(rr.Body.String()).Should(MatchJSON(`{ + Expect(rr.Body.String()).Should(MatchJSON(fmt.Sprintf(`{ "pagination": { "total_results": 2, "total_pages": 1, "first": { - "href": "https://api.example.org/v3/apps?page=1" + "href": "%[1]s/v3/apps?page=1" }, "last": { - "href": "https://api.example.org/v3/apps?page=1" + "href": "%[1]s/v3/apps?page=1" }, "next": null, "previous": null @@ -639,45 +641,45 @@ var _ = Describe("AppHandler", func() { }, "links": { "self": { - "href": "https://api.example.org/v3/apps/first-test-app-guid" + "href": "%[1]s/v3/apps/first-test-app-guid" }, "environment_variables": { - "href": "https://api.example.org/v3/apps/first-test-app-guid/environment_variables" + "href": "%[1]s/v3/apps/first-test-app-guid/environment_variables" }, "space": { - "href": "https://api.example.org/v3/spaces/test-space-guid" + "href": "%[1]s/v3/spaces/test-space-guid" }, "processes": { - "href": "https://api.example.org/v3/apps/first-test-app-guid/processes" + "href": "%[1]s/v3/apps/first-test-app-guid/processes" }, "packages": { - "href": "https://api.example.org/v3/apps/first-test-app-guid/packages" + "href": "%[1]s/v3/apps/first-test-app-guid/packages" }, "current_droplet": { - "href": "https://api.example.org/v3/apps/first-test-app-guid/droplets/current" + "href": "%[1]s/v3/apps/first-test-app-guid/droplets/current" }, "droplets": { - "href": "https://api.example.org/v3/apps/first-test-app-guid/droplets" + "href": "%[1]s/v3/apps/first-test-app-guid/droplets" }, "tasks": { - "href": "https://api.example.org/v3/apps/first-test-app-guid/tasks" + "href": "%[1]s/v3/apps/first-test-app-guid/tasks" }, "start": { - "href": "https://api.example.org/v3/apps/first-test-app-guid/actions/start", + "href": "%[1]s/v3/apps/first-test-app-guid/actions/start", "method": "POST" }, "stop": { - "href": "https://api.example.org/v3/apps/first-test-app-guid/actions/stop", + "href": "%[1]s/v3/apps/first-test-app-guid/actions/stop", "method": "POST" }, "revisions": { - "href": "https://api.example.org/v3/apps/first-test-app-guid/revisions" + "href": "%[1]s/v3/apps/first-test-app-guid/revisions" }, "deployed_revisions": { - "href": "https://api.example.org/v3/apps/first-test-app-guid/revisions/deployed" + "href": "%[1]s/v3/apps/first-test-app-guid/revisions/deployed" }, "features": { - "href": "https://api.example.org/v3/apps/first-test-app-guid/features" + "href": "%[1]s/v3/apps/first-test-app-guid/features" } } }, @@ -707,50 +709,50 @@ var _ = Describe("AppHandler", func() { }, "links": { "self": { - "href": "https://api.example.org/v3/apps/second-test-app-guid" + "href": "%[1]s/v3/apps/second-test-app-guid" }, "environment_variables": { - "href": "https://api.example.org/v3/apps/second-test-app-guid/environment_variables" + "href": "%[1]s/v3/apps/second-test-app-guid/environment_variables" }, "space": { - "href": "https://api.example.org/v3/spaces/test-space-guid" + "href": "%[1]s/v3/spaces/test-space-guid" }, "processes": { - "href": "https://api.example.org/v3/apps/second-test-app-guid/processes" + "href": "%[1]s/v3/apps/second-test-app-guid/processes" }, "packages": { - "href": "https://api.example.org/v3/apps/second-test-app-guid/packages" + "href": "%[1]s/v3/apps/second-test-app-guid/packages" }, "current_droplet": { - "href": "https://api.example.org/v3/apps/second-test-app-guid/droplets/current" + "href": "%[1]s/v3/apps/second-test-app-guid/droplets/current" }, "droplets": { - "href": "https://api.example.org/v3/apps/second-test-app-guid/droplets" + "href": "%[1]s/v3/apps/second-test-app-guid/droplets" }, "tasks": { - "href": "https://api.example.org/v3/apps/second-test-app-guid/tasks" + "href": "%[1]s/v3/apps/second-test-app-guid/tasks" }, "start": { - "href": "https://api.example.org/v3/apps/second-test-app-guid/actions/start", + "href": "%[1]s/v3/apps/second-test-app-guid/actions/start", "method": "POST" }, "stop": { - "href": "https://api.example.org/v3/apps/second-test-app-guid/actions/stop", + "href": "%[1]s/v3/apps/second-test-app-guid/actions/stop", "method": "POST" }, "revisions": { - "href": "https://api.example.org/v3/apps/second-test-app-guid/revisions" + "href": "%[1]s/v3/apps/second-test-app-guid/revisions" }, "deployed_revisions": { - "href": "https://api.example.org/v3/apps/second-test-app-guid/revisions/deployed" + "href": "%[1]s/v3/apps/second-test-app-guid/revisions/deployed" }, "features": { - "href": "https://api.example.org/v3/apps/second-test-app-guid/features" + "href": "%[1]s/v3/apps/second-test-app-guid/features" } } } ] - }`), "Response body matches response:") + }`, defaultServerURL)), "Response body matches response:") }) }) @@ -769,21 +771,21 @@ var _ = Describe("AppHandler", func() { }) It("returns a CF API formatted Error response", func() { - Expect(rr.Body.String()).Should(MatchJSON(`{ + Expect(rr.Body.String()).Should(MatchJSON(fmt.Sprintf(`{ "pagination": { "total_results": 0, "total_pages": 1, "first": { - "href": "https://api.example.org/v3/apps?page=1" + "href": "%[1]s/v3/apps?page=1" }, "last": { - "href": "https://api.example.org/v3/apps?page=1" + "href": "%[1]s/v3/apps?page=1" }, "next": null, "previous": null }, "resources": [] - }`), "Response body matches response:") + }`, defaultServerURL)), "Response body matches response:") }) }) diff --git a/apis/build_handler.go b/apis/build_handler.go index cd5356b..e61f1f0 100644 --- a/apis/build_handler.go +++ b/apis/build_handler.go @@ -1,10 +1,12 @@ package apis import ( - "code.cloudfoundry.org/cf-k8s-api/payloads" "context" "encoding/json" "net/http" + "net/url" + + "code.cloudfoundry.org/cf-k8s-api/payloads" "code.cloudfoundry.org/cf-k8s-api/presenter" "code.cloudfoundry.org/cf-k8s-api/repositories" @@ -27,7 +29,7 @@ type CFBuildRepository interface { } type BuildHandler struct { - serverURL string + serverURL url.URL buildRepo CFBuildRepository buildClient ClientBuilder packageRepo CFPackageRepository @@ -37,7 +39,7 @@ type BuildHandler struct { func NewBuildHandler( logger logr.Logger, - serverURL string, + serverURL url.URL, buildRepo CFBuildRepository, packageRepo CFPackageRepository, buildClient ClientBuilder, diff --git a/apis/build_handler_test.go b/apis/build_handler_test.go index 345296b..d30ba8b 100644 --- a/apis/build_handler_test.go +++ b/apis/build_handler_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" "strings" "code.cloudfoundry.org/cf-k8s-api/repositories" @@ -76,9 +77,11 @@ var _ = Describe("BuildHandler", func() { router = mux.NewRouter() clientBuilder = new(fake.ClientBuilder) + serverURL, err := url.Parse(defaultServerURL) + Expect(err).NotTo(HaveOccurred()) buildHandler := NewBuildHandler( logf.Log.WithName(testBuildHandlerLoggerName), - defaultServerURL, + *serverURL, buildRepo, new(fake.CFPackageRepository), clientBuilder.Spy, @@ -146,7 +149,6 @@ var _ = Describe("BuildHandler", func() { }) }) When("build staging is successful", func() { - BeforeEach(func() { buildRepo.FetchBuildReturns(repositories.BuildRecord{ GUID: buildGUID, @@ -407,10 +409,12 @@ var _ = Describe("BuildHandler", func() { AppGUID: appGUID, }, nil) + serverURL, err := url.Parse(defaultServerURL) + Expect(err).NotTo(HaveOccurred()) clientBuilder = new(fake.ClientBuilder) buildHandler := NewBuildHandler( logf.Log.WithName(testBuildHandlerLoggerName), - defaultServerURL, + *serverURL, buildRepo, packageRepo, clientBuilder.Spy, @@ -438,9 +442,7 @@ var _ = Describe("BuildHandler", func() { }) When("examining the BuildCreate message", func() { - var ( - actualCreate repositories.BuildCreateMessage - ) + var actualCreate repositories.BuildCreateMessage BeforeEach(func() { Expect(buildRepo.CreateBuildCallCount()).To(Equal(1), "buildRepo CreateBuild was not called") _, _, actualCreate = buildRepo.CreateBuildArgsForCall(0) @@ -456,7 +458,6 @@ var _ = Describe("BuildHandler", func() { }) It("fills in values for StagingMemoryMB", func() { Expect(actualCreate.StagingMemoryMB).To(Equal(expectedStagingMem)) - }) It("fills in values for StagingDiskMB", func() { Expect(actualCreate.StagingDiskMB).To(Equal(expectedStagingDisk)) diff --git a/apis/droplet_handler.go b/apis/droplet_handler.go index b5941a8..15e3c0f 100644 --- a/apis/droplet_handler.go +++ b/apis/droplet_handler.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "net/url" "sigs.k8s.io/controller-runtime/pkg/client" @@ -25,7 +26,7 @@ type CFDropletRepository interface { } type DropletHandler struct { - serverURL string + serverURL url.URL dropletRepo CFDropletRepository buildClient ClientBuilder logger logr.Logger @@ -34,7 +35,7 @@ type DropletHandler struct { func NewDropletHandler( logger logr.Logger, - serverURL string, + serverURL url.URL, dropletRepo CFDropletRepository, buildClient ClientBuilder, k8sConfig *rest.Config) *DropletHandler { @@ -84,7 +85,6 @@ func (h *DropletHandler) dropletGetHandler(w http.ResponseWriter, r *http.Reques return } w.Write(responseBody) - } func (h *DropletHandler) RegisterRoutes(router *mux.Router) { diff --git a/apis/droplet_handler_test.go b/apis/droplet_handler_test.go index 3282a74..b911899 100644 --- a/apis/droplet_handler_test.go +++ b/apis/droplet_handler_test.go @@ -4,6 +4,7 @@ import ( "errors" "net/http" "net/http/httptest" + "net/url" "code.cloudfoundry.org/cf-k8s-api/repositories" @@ -24,7 +25,7 @@ var _ = Describe("DropletHandler", func() { const ( appGUID = "test-app-guid" packageGUID = "test-package-guid" - dropletGUID = "test-build-guid" //same as build guid + dropletGUID = "test-build-guid" // same as build guid createdAt = "1906-04-18T13:12:00Z" updatedAt = "1906-04-18T13:12:01Z" @@ -49,9 +50,11 @@ var _ = Describe("DropletHandler", func() { router = mux.NewRouter() clientBuilder = new(fake.ClientBuilder) + serverURL, err := url.Parse(defaultServerURL) + Expect(err).NotTo(HaveOccurred()) dropletHandler := NewDropletHandler( logf.Log.WithName(testDropletHandlerLoggerName), - defaultServerURL, + *serverURL, dropletRepo, clientBuilder.Spy, &rest.Config{}, @@ -59,9 +62,7 @@ var _ = Describe("DropletHandler", func() { dropletHandler.RegisterRoutes(router) }) When("on the happy path", func() { - When("build staging is successful", func() { - BeforeEach(func() { dropletRepo.FetchDropletReturns(repositories.DropletRecord{ GUID: dropletGUID, @@ -158,7 +159,6 @@ var _ = Describe("DropletHandler", func() { } }`), "Response body matches response:") }) - }) }) When("building the k8s client errors", func() { diff --git a/apis/fake/cforg_repository.go b/apis/fake/cforg_repository.go index 3fac261..702fe11 100644 --- a/apis/fake/cforg_repository.go +++ b/apis/fake/cforg_repository.go @@ -10,6 +10,20 @@ import ( ) type CFOrgRepository struct { + CreateOrgStub func(context.Context, repositories.OrgRecord) (repositories.OrgRecord, error) + createOrgMutex sync.RWMutex + createOrgArgsForCall []struct { + arg1 context.Context + arg2 repositories.OrgRecord + } + createOrgReturns struct { + result1 repositories.OrgRecord + result2 error + } + createOrgReturnsOnCall map[int]struct { + result1 repositories.OrgRecord + result2 error + } FetchOrgsStub func(context.Context, []string) ([]repositories.OrgRecord, error) fetchOrgsMutex sync.RWMutex fetchOrgsArgsForCall []struct { @@ -28,6 +42,71 @@ type CFOrgRepository struct { invocationsMutex sync.RWMutex } +func (fake *CFOrgRepository) CreateOrg(arg1 context.Context, arg2 repositories.OrgRecord) (repositories.OrgRecord, error) { + fake.createOrgMutex.Lock() + ret, specificReturn := fake.createOrgReturnsOnCall[len(fake.createOrgArgsForCall)] + fake.createOrgArgsForCall = append(fake.createOrgArgsForCall, struct { + arg1 context.Context + arg2 repositories.OrgRecord + }{arg1, arg2}) + stub := fake.CreateOrgStub + fakeReturns := fake.createOrgReturns + fake.recordInvocation("CreateOrg", []interface{}{arg1, arg2}) + fake.createOrgMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *CFOrgRepository) CreateOrgCallCount() int { + fake.createOrgMutex.RLock() + defer fake.createOrgMutex.RUnlock() + return len(fake.createOrgArgsForCall) +} + +func (fake *CFOrgRepository) CreateOrgCalls(stub func(context.Context, repositories.OrgRecord) (repositories.OrgRecord, error)) { + fake.createOrgMutex.Lock() + defer fake.createOrgMutex.Unlock() + fake.CreateOrgStub = stub +} + +func (fake *CFOrgRepository) CreateOrgArgsForCall(i int) (context.Context, repositories.OrgRecord) { + fake.createOrgMutex.RLock() + defer fake.createOrgMutex.RUnlock() + argsForCall := fake.createOrgArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *CFOrgRepository) CreateOrgReturns(result1 repositories.OrgRecord, result2 error) { + fake.createOrgMutex.Lock() + defer fake.createOrgMutex.Unlock() + fake.CreateOrgStub = nil + fake.createOrgReturns = struct { + result1 repositories.OrgRecord + result2 error + }{result1, result2} +} + +func (fake *CFOrgRepository) CreateOrgReturnsOnCall(i int, result1 repositories.OrgRecord, result2 error) { + fake.createOrgMutex.Lock() + defer fake.createOrgMutex.Unlock() + fake.CreateOrgStub = nil + if fake.createOrgReturnsOnCall == nil { + fake.createOrgReturnsOnCall = make(map[int]struct { + result1 repositories.OrgRecord + result2 error + }) + } + fake.createOrgReturnsOnCall[i] = struct { + result1 repositories.OrgRecord + result2 error + }{result1, result2} +} + func (fake *CFOrgRepository) FetchOrgs(arg1 context.Context, arg2 []string) ([]repositories.OrgRecord, error) { var arg2Copy []string if arg2 != nil { @@ -101,6 +180,8 @@ func (fake *CFOrgRepository) FetchOrgsReturnsOnCall(i int, result1 []repositorie func (fake *CFOrgRepository) Invocations() map[string][][]interface{} { fake.invocationsMutex.RLock() defer fake.invocationsMutex.RUnlock() + fake.createOrgMutex.RLock() + defer fake.createOrgMutex.RUnlock() fake.fetchOrgsMutex.RLock() defer fake.fetchOrgsMutex.RUnlock() copiedInvocations := map[string][][]interface{}{} diff --git a/apis/fake/cfspace_repository.go b/apis/fake/cfspace_repository.go new file mode 100644 index 0000000..644b62a --- /dev/null +++ b/apis/fake/cfspace_repository.go @@ -0,0 +1,132 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package fake + +import ( + "context" + "sync" + + "code.cloudfoundry.org/cf-k8s-api/apis" + "code.cloudfoundry.org/cf-k8s-api/repositories" +) + +type CFSpaceRepository struct { + FetchSpacesStub func(context.Context, []string, []string) ([]repositories.SpaceRecord, error) + fetchSpacesMutex sync.RWMutex + fetchSpacesArgsForCall []struct { + arg1 context.Context + arg2 []string + arg3 []string + } + fetchSpacesReturns struct { + result1 []repositories.SpaceRecord + result2 error + } + fetchSpacesReturnsOnCall map[int]struct { + result1 []repositories.SpaceRecord + result2 error + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *CFSpaceRepository) FetchSpaces(arg1 context.Context, arg2 []string, arg3 []string) ([]repositories.SpaceRecord, error) { + var arg2Copy []string + if arg2 != nil { + arg2Copy = make([]string, len(arg2)) + copy(arg2Copy, arg2) + } + var arg3Copy []string + if arg3 != nil { + arg3Copy = make([]string, len(arg3)) + copy(arg3Copy, arg3) + } + fake.fetchSpacesMutex.Lock() + ret, specificReturn := fake.fetchSpacesReturnsOnCall[len(fake.fetchSpacesArgsForCall)] + fake.fetchSpacesArgsForCall = append(fake.fetchSpacesArgsForCall, struct { + arg1 context.Context + arg2 []string + arg3 []string + }{arg1, arg2Copy, arg3Copy}) + stub := fake.FetchSpacesStub + fakeReturns := fake.fetchSpacesReturns + fake.recordInvocation("FetchSpaces", []interface{}{arg1, arg2Copy, arg3Copy}) + fake.fetchSpacesMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *CFSpaceRepository) FetchSpacesCallCount() int { + fake.fetchSpacesMutex.RLock() + defer fake.fetchSpacesMutex.RUnlock() + return len(fake.fetchSpacesArgsForCall) +} + +func (fake *CFSpaceRepository) FetchSpacesCalls(stub func(context.Context, []string, []string) ([]repositories.SpaceRecord, error)) { + fake.fetchSpacesMutex.Lock() + defer fake.fetchSpacesMutex.Unlock() + fake.FetchSpacesStub = stub +} + +func (fake *CFSpaceRepository) FetchSpacesArgsForCall(i int) (context.Context, []string, []string) { + fake.fetchSpacesMutex.RLock() + defer fake.fetchSpacesMutex.RUnlock() + argsForCall := fake.fetchSpacesArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *CFSpaceRepository) FetchSpacesReturns(result1 []repositories.SpaceRecord, result2 error) { + fake.fetchSpacesMutex.Lock() + defer fake.fetchSpacesMutex.Unlock() + fake.FetchSpacesStub = nil + fake.fetchSpacesReturns = struct { + result1 []repositories.SpaceRecord + result2 error + }{result1, result2} +} + +func (fake *CFSpaceRepository) FetchSpacesReturnsOnCall(i int, result1 []repositories.SpaceRecord, result2 error) { + fake.fetchSpacesMutex.Lock() + defer fake.fetchSpacesMutex.Unlock() + fake.FetchSpacesStub = nil + if fake.fetchSpacesReturnsOnCall == nil { + fake.fetchSpacesReturnsOnCall = make(map[int]struct { + result1 []repositories.SpaceRecord + result2 error + }) + } + fake.fetchSpacesReturnsOnCall[i] = struct { + result1 []repositories.SpaceRecord + result2 error + }{result1, result2} +} + +func (fake *CFSpaceRepository) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + fake.fetchSpacesMutex.RLock() + defer fake.fetchSpacesMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *CFSpaceRepository) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} + +var _ apis.CFSpaceRepository = new(CFSpaceRepository) diff --git a/apis/org_handler.go b/apis/org_handler.go index ba4acc0..2881306 100644 --- a/apis/org_handler.go +++ b/apis/org_handler.go @@ -4,30 +4,37 @@ import ( "context" "encoding/json" "net/http" + "net/url" "strings" + "code.cloudfoundry.org/cf-k8s-api/payloads" "code.cloudfoundry.org/cf-k8s-api/presenter" "code.cloudfoundry.org/cf-k8s-api/repositories" "github.com/go-logr/logr" + "github.com/google/uuid" "github.com/gorilla/mux" controllerruntime "sigs.k8s.io/controller-runtime" ) -const OrgListEndpoint = "/v3/organizations" +const ( + OrgListEndpoint = "/v3/organizations" +) //counterfeiter:generate -o fake -fake-name CFOrgRepository . CFOrgRepository type CFOrgRepository interface { + // TODO: pass received credentials to OrgRepo so it can create user-auth'ed k8s client + CreateOrg(context.Context, repositories.OrgRecord) (repositories.OrgRecord, error) FetchOrgs(context.Context, []string) ([]repositories.OrgRecord, error) } type OrgHandler struct { orgRepo CFOrgRepository logger logr.Logger - apiBaseURL string + apiBaseURL url.URL } -func NewOrgHandler(orgRepo CFOrgRepository, apiBaseURL string) *OrgHandler { +func NewOrgHandler(orgRepo CFOrgRepository, apiBaseURL url.URL) *OrgHandler { return &OrgHandler{ orgRepo: orgRepo, apiBaseURL: apiBaseURL, @@ -35,7 +42,35 @@ func NewOrgHandler(orgRepo CFOrgRepository, apiBaseURL string) *OrgHandler { } } -func (h *OrgHandler) OrgListHandler(w http.ResponseWriter, r *http.Request) { +func (h *OrgHandler) orgCreateHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + w.Header().Set("Content-Type", "application/json") + + var payload payloads.OrgCreate + rme := DecodeAndValidatePayload(r, &payload) + if rme != nil { + writeErrorResponse(w, rme) + + return + } + + org := payload.ToRecord() + org.GUID = uuid.New().String() + + record, err := h.orgRepo.CreateOrg(ctx, org) + if err != nil { + h.logger.Error(err, "failed to create org") + writeUnknownErrorResponse(w) + + return + } + + w.WriteHeader(http.StatusCreated) + orgResponse := presenter.ForCreateOrg(record, h.apiBaseURL) + json.NewEncoder(w).Encode(orgResponse) +} + +func (h *OrgHandler) orgListHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() w.Header().Set("Content-Type", "application/json") @@ -58,5 +93,6 @@ func (h *OrgHandler) OrgListHandler(w http.ResponseWriter, r *http.Request) { } func (h *OrgHandler) RegisterRoutes(router *mux.Router) { - router.Path(OrgListEndpoint).Methods("GET").HandlerFunc(h.OrgListHandler) + router.Path(OrgListEndpoint).Methods("GET").HandlerFunc(h.orgListHandler) + router.Path(OrgListEndpoint).Methods("POST").HandlerFunc(h.orgCreateHandler) } diff --git a/apis/org_handler_test.go b/apis/org_handler_test.go index 47d4b29..7db60c4 100644 --- a/apis/org_handler_test.go +++ b/apis/org_handler_test.go @@ -6,6 +6,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" + "strings" "time" "code.cloudfoundry.org/cf-k8s-api/apis" @@ -22,22 +24,228 @@ const ( ) var _ = Describe("OrgHandler", func() { - Describe("Listing Orgs", func() { - var ( - ctx context.Context - router *mux.Router - orgHandler *apis.OrgHandler - orgRepo *fake.CFOrgRepository - req *http.Request - rr *httptest.ResponseRecorder - err error - ) + var ( + ctx context.Context + router *mux.Router + orgHandler *apis.OrgHandler + orgRepo *fake.CFOrgRepository + req *http.Request + rr *httptest.ResponseRecorder + err error + now time.Time + ) + + BeforeEach(func() { + now = time.Unix(1631892190, 0) // 2021-09-17T15:23:10Z + }) + + Describe("Create Org", func() { + makePostRequest := func(requestBody string) { + req, err := http.NewRequestWithContext(ctx, "POST", orgsBase, strings.NewReader(requestBody)) + Expect(err).NotTo(HaveOccurred()) + + router.ServeHTTP(rr, req) + } + + BeforeEach(func() { + ctx = context.Background() + orgRepo = new(fake.CFOrgRepository) + orgRepo.CreateOrgStub = func(_ context.Context, record repositories.OrgRecord) (repositories.OrgRecord, error) { + record.GUID = "t-h-e-o-r-g" + record.CreatedAt = now + record.UpdatedAt = now + return record, nil + } + + serverURL, err := url.Parse(defaultServerURL) + Expect(err).NotTo(HaveOccurred()) + + orgHandler = apis.NewOrgHandler(orgRepo, *serverURL) + router = mux.NewRouter() + orgHandler.RegisterRoutes(router) + + rr = httptest.NewRecorder() + }) + + When("happy path", func() { + BeforeEach(func() { + makePostRequest(`{"name": "the-org"}`) + }) + + It("returns 201 with appropriate success JSON", func() { + Expect(rr).To(HaveHTTPStatus(http.StatusCreated)) + Expect(rr).To(HaveHTTPHeaderWithValue("Content-Type", "application/json")) + Expect(rr).To(HaveHTTPBody(MatchJSON(fmt.Sprintf(`{ + "guid": "t-h-e-o-r-g", + "name": "the-org", + "created_at": "2021-09-17T15:23:10Z", + "updated_at": "2021-09-17T15:23:10Z", + "suspended": false, + "metadata": { + "labels": {}, + "annotations": {} + }, + "relationships": {}, + "links": { + "self": { + "href": "%[1]s/v3/organizations/t-h-e-o-r-g" + } + } + }`, defaultServerURL)))) + }) + + It("invokes the repo org create function with expected parameters", func() { + Expect(orgRepo.CreateOrgCallCount()).To(Equal(1)) + _, orgRecord := orgRepo.CreateOrgArgsForCall(0) + Expect(orgRecord.Name).To(Equal("the-org")) + Expect(orgRecord.Suspended).To(BeFalse()) + Expect(orgRecord.Labels).To(BeEmpty()) + Expect(orgRecord.Annotations).To(BeEmpty()) + }) + }) + + When("the org repo returns an error", func() { + BeforeEach(func() { + orgRepo.CreateOrgReturns(repositories.OrgRecord{}, errors.New("boom")) + makePostRequest(`{"name": "the-org"}`) + }) + + itRespondsWithUnknownError(func() *httptest.ResponseRecorder { return rr }) + }) + + When("the user passes optional org parameters", func() { + BeforeEach(func() { + makePostRequest(`{ + "name": "the-org", + "suspended": true, + "metadata": { + "labels": {"foo": "bar"}, + "annotations": {"bar": "baz"} + } + }`) + }) + + It("invokes the repo org create function with expected parameters", func() { + Expect(rr).To(HaveHTTPStatus(http.StatusCreated)) + Expect(rr).To(HaveHTTPHeaderWithValue("Content-Type", "application/json")) + Expect(orgRepo.CreateOrgCallCount()).To(Equal(1)) + _, orgRecord := orgRepo.CreateOrgArgsForCall(0) + Expect(orgRecord.Name).To(Equal("the-org")) + Expect(orgRecord.Suspended).To(BeTrue()) + Expect(orgRecord.Labels).To(And(HaveLen(1), HaveKeyWithValue("foo", "bar"))) + Expect(orgRecord.Annotations).To(And(HaveLen(1), HaveKeyWithValue("bar", "baz"))) + }) + + It("returns 201 with appropriate success JSON", func() { + Expect(rr).To(HaveHTTPStatus(http.StatusCreated)) + Expect(rr).To(HaveHTTPHeaderWithValue("Content-Type", "application/json")) + Expect(rr).To(HaveHTTPBody(MatchJSON(fmt.Sprintf(`{ + "guid": "t-h-e-o-r-g", + "name": "the-org", + "created_at": "2021-09-17T15:23:10Z", + "updated_at": "2021-09-17T15:23:10Z", + "suspended": true, + "metadata": { + "labels": {"foo": "bar"}, + "annotations": {"bar": "baz"} + }, + "relationships": {}, + "links": { + "self": { + "href": "%[1]s/v3/organizations/t-h-e-o-r-g" + } + } + }`, defaultServerURL)))) + }) + }) + + When("the request body is invalid json", func() { + BeforeEach(func() { + makePostRequest(`{`) + }) + + It("returns a status 400 with appropriate error JSON", func() { + Expect(rr).To(HaveHTTPStatus(http.StatusBadRequest)) + Expect(rr).To(HaveHTTPHeaderWithValue("Content-Type", "application/json")) + Expect(rr).To(HaveHTTPBody(MatchJSON(`{ + "errors": [ + { + "title": "CF-MessageParseError", + "detail": "Request invalid due to parse error: invalid request body", + "code": 1001 + } + ] + }`))) + }) + }) + + When("the request body has an unknown field", func() { + BeforeEach(func() { + makePostRequest(`{"description" : "Invalid Request"}`) + }) + + It("returns a status 422 with appropriate error JSON", func() { + Expect(rr).To(HaveHTTPStatus(http.StatusUnprocessableEntity)) + Expect(rr).To(HaveHTTPHeaderWithValue("Content-Type", "application/json")) + Expect(rr).To(HaveHTTPBody(MatchJSON(`{ + "errors": [ + { + "title": "CF-UnprocessableEntity", + "detail": "invalid request body: json: unknown field \"description\"", + "code": 10008 + } + ] + }`))) + }) + }) + + When("the request body is invalid with invalid app name", func() { + BeforeEach(func() { + makePostRequest(`{"name": 12345}`) + }) + It("returns a status 422 with appropriate error JSON", func() { + Expect(rr).To(HaveHTTPStatus(http.StatusUnprocessableEntity)) + Expect(rr).To(HaveHTTPHeaderWithValue("Content-Type", "application/json")) + Expect(rr).To(HaveHTTPBody(MatchJSON(`{ + "errors": [ + { + "code": 10008, + "title": "CF-UnprocessableEntity", + "detail": "Name must be a string" + } + ] + }`))) + }) + }) + + When("the request body is invalid with missing required name field", func() { + BeforeEach(func() { + makePostRequest(`{"metadata": {"labels": {"foo": "bar"}}}`) + }) + + It("returns a status 422 with appropriate error message json", func() { + Expect(rr).To(HaveHTTPStatus(http.StatusUnprocessableEntity)) + Expect(rr).To(HaveHTTPHeaderWithValue("Content-Type", "application/json")) + Expect(rr).To(HaveHTTPBody(MatchJSON(`{ + "errors": [ + { + "title": "CF-UnprocessableEntity", + "detail": "Name is a required field", + "code": 10008 + } + ] + }`))) + }) + }) + }) + + Describe("Listing Orgs", func() { BeforeEach(func() { ctx = context.Background() orgRepo = new(fake.CFOrgRepository) - now := time.Unix(1631892190, 0) // 2021-09-17T15:23:10Z + now = time.Unix(1631892190, 0) // 2021-09-17T15:23:10Z orgRepo.FetchOrgsReturns([]repositories.OrgRecord{ { Name: "alice", @@ -53,7 +261,9 @@ var _ = Describe("OrgHandler", func() { }, }, nil) - orgHandler = apis.NewOrgHandler(orgRepo, rootURL) + serverURL, err := url.Parse(rootURL) + Expect(err).NotTo(HaveOccurred()) + orgHandler = apis.NewOrgHandler(orgRepo, *serverURL) router = mux.NewRouter() orgHandler.RegisterRoutes(router) @@ -83,56 +293,56 @@ var _ = Describe("OrgHandler", func() { It("renders the orgs response", func() { expectedBody := fmt.Sprintf(` - { - "pagination": { - "total_results": 2, - "total_pages": 1, - "first": { - "href": "%[1]s/v3/organizations?page=1" + { + "pagination": { + "total_results": 2, + "total_pages": 1, + "first": { + "href": "%[1]s/v3/organizations?page=1" + }, + "last": { + "href": "%[1]s/v3/organizations?page=1" + }, + "next": null, + "previous": null + }, + "resources": [ + { + "guid": "a-l-i-c-e", + "name": "alice", + "created_at": "2021-09-17T15:23:10Z", + "updated_at": "2021-09-17T15:23:10Z", + "suspended": false, + "metadata": { + "labels": {}, + "annotations": {} }, - "last": { - "href": "%[1]s/v3/organizations?page=1" + "relationships": {}, + "links": { + "self": { + "href": "%[1]s/v3/organizations/a-l-i-c-e" + } + } + }, + { + "guid": "b-o-b", + "name": "bob", + "created_at": "2021-09-17T15:23:10Z", + "updated_at": "2021-09-17T15:23:10Z", + "suspended": false, + "metadata": { + "labels": {}, + "annotations": {} }, - "next": null, - "previous": null - }, - "resources": [ - { - "guid": "a-l-i-c-e", - "name": "alice", - "created_at": "2021-09-17T15:23:10Z", - "updated_at": "2021-09-17T15:23:10Z", - "suspended": false, - "metadata": { - "labels": {}, - "annotations": {} - }, - "relationships": {}, - "links": { - "self": { - "href": "%[1]s/v3/organizations/a-l-i-c-e" - } - } - }, - { - "guid": "b-o-b", - "name": "bob", - "created_at": "2021-09-17T15:23:10Z", - "updated_at": "2021-09-17T15:23:10Z", - "suspended": false, - "metadata": { - "labels": {}, - "annotations": {} - }, - "relationships": {}, - "links": { - "self": { - "href": "%[1]s/v3/organizations/b-o-b" - } - } - } - ] - }`, rootURL) + "relationships": {}, + "links": { + "self": { + "href": "%[1]s/v3/organizations/b-o-b" + } + } + } + ] + }`, rootURL) Expect(rr.Body.String()).To(MatchJSON(expectedBody)) }) }) diff --git a/apis/package_handler.go b/apis/package_handler.go index a29e97a..d6706dd 100644 --- a/apis/package_handler.go +++ b/apis/package_handler.go @@ -7,6 +7,7 @@ import ( "fmt" "mime/multipart" "net/http" + "net/url" "github.com/google/go-containerregistry/pkg/v1/remote" @@ -46,7 +47,7 @@ type RegistryAuthBuilder func(ctx context.Context) (remote.Option, error) type PackageHandler struct { logger logr.Logger - serverURL string + serverURL url.URL packageRepo CFPackageRepository appRepo CFAppRepository buildClient ClientBuilder @@ -57,7 +58,17 @@ type PackageHandler struct { registrySecretName string } -func NewPackageHandler(logger logr.Logger, serverURL string, packageRepo CFPackageRepository, appRepo CFAppRepository, buildClient ClientBuilder, uploadSourceImage SourceImageUploader, buildRegistryAuth RegistryAuthBuilder, k8sConfig *rest.Config, registryBase string, registrySecretName string) *PackageHandler { +func NewPackageHandler( + logger logr.Logger, + serverURL url.URL, + packageRepo CFPackageRepository, + appRepo CFAppRepository, + buildClient ClientBuilder, + uploadSourceImage SourceImageUploader, + buildRegistryAuth RegistryAuthBuilder, + k8sConfig *rest.Config, + registryBase string, + registrySecretName string) *PackageHandler { return &PackageHandler{ logger: logger, serverURL: serverURL, diff --git a/apis/package_handler_test.go b/apis/package_handler_test.go index c34446d..723090d 100644 --- a/apis/package_handler_test.go +++ b/apis/package_handler_test.go @@ -8,6 +8,7 @@ import ( "mime/multipart" "net/http" "net/http/httptest" + "net/url" "strings" "github.com/google/go-containerregistry/pkg/v1/remote" @@ -86,7 +87,18 @@ var _ = Describe("PackageHandler", func() { clientBuilder = new(fake.ClientBuilder) - apiHandler := NewPackageHandler(logf.Log.WithName(testPackageHandlerLoggerName), defaultServerURL, packageRepo, appRepo, clientBuilder.Spy, nil, nil, &rest.Config{}, "", "") + serverURL, err := url.Parse(defaultServerURL) + Expect(err).NotTo(HaveOccurred()) + apiHandler := NewPackageHandler( + logf.Log.WithName(testPackageHandlerLoggerName), + *serverURL, + packageRepo, + appRepo, + clientBuilder.Spy, + nil, nil, + &rest.Config{}, + "", "", + ) apiHandler.RegisterRoutes(router) }) @@ -355,9 +367,11 @@ var _ = Describe("PackageHandler", func() { buildRegistryAuth = new(fake.RegistryAuthBuilder) buildRegistryAuth.Returns(credentialOption, nil) + serverURL, err := url.Parse(defaultServerURL) + Expect(err).NotTo(HaveOccurred()) apiHandler := NewPackageHandler( logf.Log.WithName(testPackageHandlerLoggerName), - defaultServerURL, + *serverURL, packageRepo, appRepo, clientBuilder.Spy, diff --git a/apis/route_handler.go b/apis/route_handler.go index c2d0455..7898704 100644 --- a/apis/route_handler.go +++ b/apis/route_handler.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "net/http" + "net/url" "code.cloudfoundry.org/cf-k8s-api/payloads" "code.cloudfoundry.org/cf-k8s-api/presenter" @@ -37,7 +38,7 @@ type CFDomainRepository interface { type RouteHandler struct { logger logr.Logger - serverURL string + serverURL url.URL routeRepo CFRouteRepository domainRepo CFDomainRepository appRepo CFAppRepository @@ -47,7 +48,7 @@ type RouteHandler struct { func NewRouteHandler( logger logr.Logger, - serverURL string, + serverURL url.URL, routeRepo CFRouteRepository, domainRepo CFDomainRepository, appRepo CFAppRepository, diff --git a/apis/route_handler_test.go b/apis/route_handler_test.go index 13befc3..7d314f6 100644 --- a/apis/route_handler_test.go +++ b/apis/route_handler_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" "strings" . "code.cloudfoundry.org/cf-k8s-api/apis" @@ -64,9 +65,11 @@ var _ = Describe("RouteHandler", func() { Name: "example.org", }, nil) + serverURL, err := url.Parse(defaultServerURL) + Expect(err).NotTo(HaveOccurred()) routeHandler := NewRouteHandler( logf.Log.WithName("TestRouteHandler"), - defaultServerURL, + *serverURL, routeRepo, domainRepo, appRepo, @@ -75,7 +78,6 @@ var _ = Describe("RouteHandler", func() { ) routeHandler.RegisterRoutes(router) - var err error req, err = http.NewRequest("GET", fmt.Sprintf("/v3/routes/%s", testRouteGUID), nil) Expect(err).NotTo(HaveOccurred()) }) @@ -97,7 +99,7 @@ var _ = Describe("RouteHandler", func() { }) It("returns the Route in the response", func() { - expectedBody := `{ + expectedBody := fmt.Sprintf(`{ "guid": "test-route-guid", "port": null, "path": "", @@ -125,19 +127,19 @@ var _ = Describe("RouteHandler", func() { }, "links": { "self":{ - "href": "https://api.example.org/v3/routes/test-route-guid" + "href": "%[1]s/v3/routes/test-route-guid" }, "space":{ - "href": "https://api.example.org/v3/spaces/test-space-guid" + "href": "%[1]s/v3/spaces/test-space-guid" }, "domain":{ - "href": "https://api.example.org/v3/domains/test-domain-guid" + "href": "%[1]s/v3/domains/test-domain-guid" }, "destinations":{ - "href": "https://api.example.org/v3/routes/test-route-guid/destinations" + "href": "%[1]s/v3/routes/test-route-guid/destinations" } } - }` + }`, defaultServerURL) Expect(rr.Body.String()).To(MatchJSON(expectedBody), "Response body matches response:") }) @@ -185,6 +187,7 @@ var _ = Describe("RouteHandler", func() { itRespondsWithUnknownError(getRR) }) }) + Describe("the POST /v3/routes endpoint", func() { const ( testDomainGUID = "test-domain-guid" @@ -222,9 +225,11 @@ var _ = Describe("RouteHandler", func() { appRepo = new(fake.CFAppRepository) clientBuilder = new(fake.ClientBuilder) + serverURL, err := url.Parse(defaultServerURL) + Expect(err).NotTo(HaveOccurred()) apiHandler := NewRouteHandler( logf.Log.WithName("TestRouteHandler"), - defaultServerURL, + *serverURL, routeRepo, domainRepo, appRepo, @@ -290,7 +295,7 @@ var _ = Describe("RouteHandler", func() { }) It("returns the created route in the response", func() { - Expect(rr.Body.String()).To(MatchJSON(`{ + Expect(rr.Body.String()).To(MatchJSON(fmt.Sprintf(`{ "guid": "test-route-guid", "protocol": "http", "port": null, @@ -318,19 +323,19 @@ var _ = Describe("RouteHandler", func() { }, "links": { "self": { - "href": "https://api.example.org/v3/routes/test-route-guid" + "href": "%[1]s/v3/routes/test-route-guid" }, "space": { - "href": "https://api.example.org/v3/spaces/test-space-guid" + "href": "%[1]s/v3/spaces/test-space-guid" }, "domain": { - "href": "https://api.example.org/v3/domains/test-domain-guid" + "href": "%[1]s/v3/domains/test-domain-guid" }, "destinations": { - "href": "https://api.example.org/v3/routes/test-route-guid/destinations" + "href": "%[1]s/v3/routes/test-route-guid/destinations" } } - }`), "Response body mismatch") + }`, defaultServerURL)), "Response body mismatch") }) }) diff --git a/apis/space_handler.go b/apis/space_handler.go new file mode 100644 index 0000000..e307c37 --- /dev/null +++ b/apis/space_handler.go @@ -0,0 +1,72 @@ +package apis + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" + + "code.cloudfoundry.org/cf-k8s-api/presenter" + "code.cloudfoundry.org/cf-k8s-api/repositories" + "github.com/go-logr/logr" + "github.com/gorilla/mux" + controllerruntime "sigs.k8s.io/controller-runtime" +) + +const ( + SpaceListEndpoint = "/v3/spaces" +) + +//counterfeiter:generate -o fake -fake-name CFSpaceRepository . CFSpaceRepository + +type CFSpaceRepository interface { + FetchSpaces(context.Context, []string, []string) ([]repositories.SpaceRecord, error) +} + +type SpaceHandler struct { + spaceRepo CFSpaceRepository + logger logr.Logger + apiBaseURL url.URL +} + +func NewSpaceHandler(spaceRepo CFSpaceRepository, apiBaseURL url.URL) *SpaceHandler { + return &SpaceHandler{ + spaceRepo: spaceRepo, + apiBaseURL: apiBaseURL, + logger: controllerruntime.Log.WithName("Org Handler"), + } +} + +func (h *SpaceHandler) SpaceListHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + w.Header().Set("Content-Type", "application/json") + + orgUIDs := parseCommaSeparatedList(r.URL.Query().Get("organization_guids")) + names := parseCommaSeparatedList(r.URL.Query().Get("names")) + + spaces, err := h.spaceRepo.FetchSpaces(ctx, orgUIDs, names) + if err != nil { + writeUnknownErrorResponse(w) + + return + } + + spaceList := presenter.ForSpaceList(spaces, h.apiBaseURL) + json.NewEncoder(w).Encode(spaceList) +} + +func (h *SpaceHandler) RegisterRoutes(router *mux.Router) { + router.Path(SpaceListEndpoint).Methods("GET").HandlerFunc(h.SpaceListHandler) +} + +func parseCommaSeparatedList(list string) []string { + var elements []string + for _, element := range strings.Split(list, ",") { + if element != "" { + elements = append(elements, element) + } + } + + return elements +} diff --git a/apis/space_handler_test.go b/apis/space_handler_test.go new file mode 100644 index 0000000..65f1b30 --- /dev/null +++ b/apis/space_handler_test.go @@ -0,0 +1,179 @@ +package apis_test + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "time" + + "code.cloudfoundry.org/cf-k8s-api/apis" + "code.cloudfoundry.org/cf-k8s-api/apis/fake" + "code.cloudfoundry.org/cf-k8s-api/repositories" + "github.com/gorilla/mux" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("Spaces", func() { + Describe("Listing Spaces", func() { + const spacesBase = "/v3/spaces" + var ( + ctx context.Context + router *mux.Router + spaceHandler *apis.SpaceHandler + spaceRepo *fake.CFSpaceRepository + req *http.Request + rr *httptest.ResponseRecorder + err error + now time.Time + ) + + BeforeEach(func() { + ctx = context.Background() + spaceRepo = new(fake.CFSpaceRepository) + + now = time.Unix(1631892190, 0) // 2021-09-17T15:23:10Z + spaceRepo.FetchSpacesReturns([]repositories.SpaceRecord{ + { + Name: "alice", + GUID: "a-l-i-c-e", + OrganizationGUID: "org-guid-1", + CreatedAt: now, + UpdatedAt: now, + }, + { + Name: "bob", + GUID: "b-o-b", + OrganizationGUID: "org-guid-2", + CreatedAt: now, + UpdatedAt: now, + }, + }, nil) + + serverURL, err := url.Parse(rootURL) + Expect(err).NotTo(HaveOccurred()) + spaceHandler = apis.NewSpaceHandler(spaceRepo, *serverURL) + router = mux.NewRouter() + spaceHandler.RegisterRoutes(router) + + rr = httptest.NewRecorder() + req, err = http.NewRequestWithContext(ctx, http.MethodGet, spacesBase, nil) + Expect(err).NotTo(HaveOccurred()) + }) + + It("returns a list of spaces", func() { + router.ServeHTTP(rr, req) + Expect(rr.Header().Get("Content-Type")).To(Equal("application/json")) + + Expect(rr.Body.String()).To(MatchJSON(fmt.Sprintf(`{ + "pagination": { + "total_results": 2, + "total_pages": 1, + "first": { + "href": "%[1]s/v3/spaces?page=1" + }, + "last": { + "href": "%[1]s/v3/spaces?page=1" + }, + "next": null, + "previous": null + }, + "resources": [ + { + "guid": "a-l-i-c-e", + "name": "alice", + "created_at": "2021-09-17T15:23:10Z", + "updated_at": "2021-09-17T15:23:10Z", + "metadata": { + "labels": {}, + "annotations": {} + }, + "relationships": { + "organization": { + "data": { + "guid": "org-guid-1" + } + } + }, + "links": { + "self": { + "href": "%[1]s/v3/spaces/a-l-i-c-e" + }, + "organization": { + "href": "%[1]s/v3/organizations/org-guid-1" + } + } + }, + { + "guid": "b-o-b", + "name": "bob", + "created_at": "2021-09-17T15:23:10Z", + "updated_at": "2021-09-17T15:23:10Z", + "metadata": { + "labels": {}, + "annotations": {} + }, + "relationships": { + "organization": { + "data": { + "guid": "org-guid-2" + } + } + }, + "links": { + "self": { + "href": "%[1]s/v3/spaces/b-o-b" + }, + "organization": { + "href": "%[1]s/v3/organizations/org-guid-2" + } + } + } + ] + }`, defaultServerURL))) + + Expect(spaceRepo.FetchSpacesCallCount()).To(Equal(1)) + _, organizationGUIDs, names := spaceRepo.FetchSpacesArgsForCall(0) + Expect(organizationGUIDs).To(BeEmpty()) + Expect(names).To(BeEmpty()) + }) + + When("fetching the spaces fails", func() { + BeforeEach(func() { + spaceRepo.FetchSpacesReturns(nil, errors.New("boom!")) + router.ServeHTTP(rr, req) + }) + + itRespondsWithUnknownError(func() *httptest.ResponseRecorder { return rr }) + }) + + When("organization_guids are provided as a comma-separated list", func() { + It("filters spaces by them", func() { + req, err = http.NewRequestWithContext(ctx, http.MethodGet, spacesBase+"?organization_guids=foo,,bar,", nil) + Expect(err).NotTo(HaveOccurred()) + router.ServeHTTP(rr, req) + + Expect(spaceRepo.FetchSpacesCallCount()).To(Equal(1)) + _, organizationGUIDs, names := spaceRepo.FetchSpacesArgsForCall(0) + Expect(organizationGUIDs).To(ConsistOf("foo", "bar")) + Expect(names).To(BeEmpty()) + }) + }) + + When("names are provided as a comma-separated list", func() { + It("filters spaces by them", func() { + req, err = http.NewRequestWithContext(ctx, http.MethodGet, spacesBase+"?organization_guids=org1&names=foo,,bar,", nil) + Expect(err).NotTo(HaveOccurred()) + router.ServeHTTP(rr, req) + + Expect(spaceRepo.FetchSpacesCallCount()).To(Equal(1)) + _, organizationGUIDs, names := spaceRepo.FetchSpacesArgsForCall(0) + Expect(organizationGUIDs).To(ConsistOf("org1")) + Expect(names).To(ConsistOf("foo", "bar")) + }) + }) + }) +}) diff --git a/config/base/rbac/role.yaml b/config/base/rbac/role.yaml index 02b4c51..5c5df94 100644 --- a/config/base/rbac/role.yaml +++ b/config/base/rbac/role.yaml @@ -35,6 +35,7 @@ rules: resources: - subnamespaceanchors verbs: + - create - list - apiGroups: - networking.cloudfoundry.org diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml deleted file mode 100644 index bcc25ba..0000000 --- a/config/rbac/role.yaml +++ /dev/null @@ -1,88 +0,0 @@ - ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - creationTimestamp: null - name: cf-admin-clusterrole -rules: -- apiGroups: - - "" - resources: - - namespaces - verbs: - - get - - list - - watch -- apiGroups: - - networking.cloudfoundry.org - resources: - - cfdomains - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - networking.cloudfoundry.org - resources: - - cfdomains/status - verbs: - - get -- apiGroups: - - networking.cloudfoundry.org - resources: - - cfroutes - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - networking.cloudfoundry.org - resources: - - cfroutes/status - verbs: - - get -- apiGroups: - - workloads.cloudfoundry.org - resources: - - cfapps - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - workloads.cloudfoundry.org - resources: - - cfapps/status - verbs: - - get -- apiGroups: - - workloads.cloudfoundry.org - resources: - - cfpackages - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - workloads.cloudfoundry.org - resources: - - cfpackages/status - verbs: - - get diff --git a/main.go b/main.go index 6a5b41e..5d091e8 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "net/http" + "net/url" "os" "github.com/pivotal/kpack/pkg/registry" @@ -66,6 +67,11 @@ func main() { panic(fmt.Sprintf("could not create privileged k8s client: %v", err)) } + serverURL, err := url.Parse(config.ServerURL) + if err != nil { + panic(fmt.Sprintf("could not parse server URL: %v", err)) + } + handlers := []APIHandler{ apis.NewRootV3Handler(config.ServerURL), apis.NewRootHandler( @@ -75,7 +81,7 @@ func main() { apis.NewResourceMatchesHandler(config.ServerURL), apis.NewAppHandler( ctrl.Log.WithName("AppHandler"), - config.ServerURL, + *serverURL, new(repositories.AppRepo), new(repositories.DropletRepo), repositories.BuildCRClient, @@ -83,7 +89,7 @@ func main() { ), apis.NewRouteHandler( ctrl.Log.WithName("RouteHandler"), - config.ServerURL, + *serverURL, new(repositories.RouteRepo), new(repositories.DomainRepo), new(repositories.AppRepo), @@ -92,7 +98,7 @@ func main() { ), apis.NewPackageHandler( ctrl.Log.WithName("PackageHandler"), - config.ServerURL, + *serverURL, new(repositories.PackageRepo), new(repositories.AppRepo), repositories.BuildCRClient, @@ -104,7 +110,7 @@ func main() { ), apis.NewBuildHandler( ctrl.Log.WithName("BuildHandler"), - config.ServerURL, + *serverURL, new(repositories.BuildRepo), new(repositories.PackageRepo), repositories.BuildCRClient, @@ -112,14 +118,18 @@ func main() { ), apis.NewDropletHandler( ctrl.Log.WithName("DropletHandler"), - config.ServerURL, + *serverURL, new(repositories.DropletRepo), repositories.BuildCRClient, k8sClientConfig, ), apis.NewOrgHandler( repositories.NewOrgRepo(config.RootNamespace, privilegedCRClient), - config.ServerURL, + *serverURL, + ), + apis.NewSpaceHandler( + repositories.NewOrgRepo(config.RootNamespace, privilegedCRClient), + *serverURL, ), } diff --git a/payloads/org.go b/payloads/org.go new file mode 100644 index 0000000..99cbfd4 --- /dev/null +++ b/payloads/org.go @@ -0,0 +1,20 @@ +package payloads + +import ( + "code.cloudfoundry.org/cf-k8s-api/repositories" +) + +type OrgCreate struct { + Name string `json:"name" validate:"required"` + Suspended bool `json:"suspended"` + Metadata Metadata `json:"metadata"` +} + +func (p OrgCreate) ToRecord() repositories.OrgRecord { + return repositories.OrgRecord{ + Name: p.Name, + Suspended: p.Suspended, + Labels: p.Metadata.Labels, + Annotations: p.Metadata.Annotations, + } +} diff --git a/presenter/app.go b/presenter/app.go index 825f506..4a6f026 100644 --- a/presenter/app.go +++ b/presenter/app.go @@ -1,11 +1,15 @@ package presenter import ( - "fmt" + "net/url" "code.cloudfoundry.org/cf-k8s-api/repositories" ) +const ( + appsBase = "/v3/apps" +) + type AppResponse struct { Name string `json:"name"` GUID string `json:"guid"` @@ -40,7 +44,7 @@ type AppListResponse struct { Resources []AppResponse `json:"resources"` } -func ForApp(responseApp repositories.AppRecord, baseURL string) AppResponse { +func ForApp(responseApp repositories.AppRecord, baseURL url.URL) AppResponse { return AppResponse{ Name: responseApp.Name, GUID: responseApp.GUID, @@ -67,51 +71,51 @@ func ForApp(responseApp repositories.AppRecord, baseURL string) AppResponse { }, Links: AppLinks{ Self: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/apps/%s", responseApp.GUID)), + HREF: buildURL(baseURL).appendPath(appsBase, responseApp.GUID).build(), }, Space: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/spaces/%s", responseApp.SpaceGUID)), + HREF: buildURL(baseURL).appendPath(spacesBase, responseApp.SpaceGUID).build(), }, Processes: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/apps/%s/processes", responseApp.GUID)), + HREF: buildURL(baseURL).appendPath(appsBase, responseApp.GUID, "processes").build(), }, Packages: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/apps/%s/packages", responseApp.GUID)), + HREF: buildURL(baseURL).appendPath(appsBase, responseApp.GUID, "packages").build(), }, EnvironmentVariables: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/apps/%s/environment_variables", responseApp.GUID)), + HREF: buildURL(baseURL).appendPath(appsBase, responseApp.GUID, "environment_variables").build(), }, CurrentDroplet: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/apps/%s/droplets/current", responseApp.GUID)), + HREF: buildURL(baseURL).appendPath(appsBase, responseApp.GUID, "droplets/current").build(), }, Droplets: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/apps/%s/droplets", responseApp.GUID)), + HREF: buildURL(baseURL).appendPath(appsBase, responseApp.GUID, "droplets").build(), }, Tasks: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/apps/%s/tasks", responseApp.GUID)), + HREF: buildURL(baseURL).appendPath(appsBase, responseApp.GUID, "tasks").build(), }, StartAction: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/apps/%s/actions/start", responseApp.GUID)), + HREF: buildURL(baseURL).appendPath(appsBase, responseApp.GUID, "actions/start").build(), Method: "POST", }, StopAction: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/apps/%s/actions/stop", responseApp.GUID)), + HREF: buildURL(baseURL).appendPath(appsBase, responseApp.GUID, "actions/stop").build(), Method: "POST", }, Revisions: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/apps/%s/revisions", responseApp.GUID)), + HREF: buildURL(baseURL).appendPath(appsBase, responseApp.GUID, "revisions").build(), }, DeployedRevisions: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/apps/%s/revisions/deployed", responseApp.GUID)), + HREF: buildURL(baseURL).appendPath(appsBase, responseApp.GUID, "revisions/deployed").build(), }, Features: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/apps/%s/features", responseApp.GUID)), + HREF: buildURL(baseURL).appendPath(appsBase, responseApp.GUID, "features").build(), }, }, } } -func ForAppList(appRecordList []repositories.AppRecord, baseURL string) AppListResponse { +func ForAppList(appRecordList []repositories.AppRecord, baseURL url.URL) AppListResponse { appResponses := make([]AppResponse, 0, len(appRecordList)) for _, app := range appRecordList { appResponses = append(appResponses, ForApp(app, baseURL)) @@ -122,10 +126,10 @@ func ForAppList(appRecordList []repositories.AppRecord, baseURL string) AppListR TotalResults: len(appResponses), TotalPages: 1, First: PageRef{ - HREF: prefixedLinkURL(baseURL, "v3/apps?page=1"), + HREF: buildURL(baseURL).appendPath(appsBase).setQuery("page=1").build(), }, Last: PageRef{ - HREF: prefixedLinkURL(baseURL, "v3/apps?page=1"), + HREF: buildURL(baseURL).appendPath(appsBase).setQuery("page=1").build(), }, }, Resources: appResponses, @@ -144,7 +148,7 @@ type CurrentDropletLinks struct { Related Link `json:"related"` } -func ForCurrentDroplet(record repositories.CurrentDropletRecord, baseURL string) CurrentDropletResponse { +func ForCurrentDroplet(record repositories.CurrentDropletRecord, baseURL url.URL) CurrentDropletResponse { return CurrentDropletResponse{ Relationship: Relationship{ Data: RelationshipData{ @@ -153,10 +157,10 @@ func ForCurrentDroplet(record repositories.CurrentDropletRecord, baseURL string) }, Links: CurrentDropletLinks{ Self: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/apps/%s/relationships/current_droplet", record.AppGUID)), + HREF: buildURL(baseURL).appendPath(appsBase, record.AppGUID, "relationships/current_droplet").build(), }, Related: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/apps/%s/droplets/current", record.AppGUID)), + HREF: buildURL(baseURL).appendPath(appsBase, record.AppGUID, "droplets/current").build(), }, }, } diff --git a/presenter/build.go b/presenter/build.go index c7544c4..784117b 100644 --- a/presenter/build.go +++ b/presenter/build.go @@ -1,8 +1,14 @@ package presenter import ( + "net/url" + "code.cloudfoundry.org/cf-k8s-api/repositories" - "fmt" +) + +const ( + buildsBase = "/v3/builds" + dropletsBase = "/v3/droplets" ) type BuildResponse struct { @@ -22,7 +28,7 @@ type BuildResponse struct { Links map[string]Link `json:"links"` } -func ForBuild(buildRecord repositories.BuildRecord, baseURL string) BuildResponse { +func ForBuild(buildRecord repositories.BuildRecord, baseURL url.URL) BuildResponse { toReturn := BuildResponse{ GUID: buildRecord.GUID, CreatedAt: buildRecord.CreatedAt, @@ -54,11 +60,11 @@ func ForBuild(buildRecord repositories.BuildRecord, baseURL string) BuildRespons Annotations: map[string]string{}, }, Links: map[string]Link{ - "self": Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/builds/%s", buildRecord.GUID)), + "self": { + HREF: buildURL(baseURL).appendPath(buildsBase, buildRecord.GUID).build(), }, - "app": Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/apps/%s", buildRecord.AppGUID)), + "app": { + HREF: buildURL(baseURL).appendPath(appsBase, buildRecord.AppGUID).build(), }, }, } @@ -69,7 +75,7 @@ func ForBuild(buildRecord repositories.BuildRecord, baseURL string) BuildRespons } toReturn.Links["droplet"] = Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/droplets/%s", buildRecord.DropletGUID)), + HREF: buildURL(baseURL).appendPath(dropletsBase, buildRecord.DropletGUID).build(), } } diff --git a/presenter/droplet.go b/presenter/droplet.go index f448c00..00e954e 100644 --- a/presenter/droplet.go +++ b/presenter/droplet.go @@ -1,7 +1,7 @@ package presenter import ( - "fmt" + "net/url" "code.cloudfoundry.org/cf-k8s-api/repositories" ) @@ -36,7 +36,7 @@ type BuildpackData struct { Version string `json:"version"` } -func ForDroplet(dropletRecord repositories.DropletRecord, baseURL string) DropletResponse { +func ForDroplet(dropletRecord repositories.DropletRecord, baseURL url.URL) DropletResponse { toReturn := DropletResponse{ GUID: dropletRecord.GUID, CreatedAt: dropletRecord.CreatedAt, @@ -66,16 +66,16 @@ func ForDroplet(dropletRecord repositories.DropletRecord, baseURL string) Drople }, Links: map[string]*Link{ "self": { - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/droplets/%s", dropletRecord.GUID)), + HREF: buildURL(baseURL).appendPath(dropletsBase, dropletRecord.GUID).build(), }, "package": { - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/packages/%s", dropletRecord.PackageGUID)), + HREF: buildURL(baseURL).appendPath(packagesBase, dropletRecord.PackageGUID).build(), }, "app": { - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/apps/%s", dropletRecord.AppGUID)), + HREF: buildURL(baseURL).appendPath(appsBase, dropletRecord.AppGUID).build(), }, "assign_current_droplet": { - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/apps/%s/relationships/current_droplet", dropletRecord.AppGUID)), + HREF: buildURL(baseURL).appendPath(appsBase, dropletRecord.AppGUID, "relationships/current_droplet").build(), Method: "PATCH", }, "download": nil, diff --git a/presenter/org.go b/presenter/org.go index d43676d..11ef33e 100644 --- a/presenter/org.go +++ b/presenter/org.go @@ -1,8 +1,7 @@ package presenter import ( - neturl "net/url" - "path" + "net/url" "time" "code.cloudfoundry.org/cf-k8s-api/repositories" @@ -10,7 +9,8 @@ import ( const ( // TODO: repetition with handler endpoint? - orgsBase = "/v3/organizations" + orgsBase = "/v3/organizations" + spacesBase = "/v3/spaces" ) type OrgListResponse struct { @@ -37,45 +37,122 @@ type OrgLinks struct { Quota *Link `json:"quota,omitempty"` } -func ForOrgList(orgs []repositories.OrgRecord, apiBaseURL string) OrgListResponse { - baseURL, _ := neturl.Parse(apiBaseURL) - baseURL.Path = orgsBase - baseURL.RawQuery = "page=1" +type SpaceListResponse struct { + Pagination PaginationData `json:"pagination"` + Resources []SpaceResponse `json:"resources"` +} - selfLink, _ := neturl.Parse(apiBaseURL) +type SpaceResponse struct { + Name string `json:"name"` + GUID string `json:"guid"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Links SpaceLinks `json:"links"` + Metadata Metadata `json:"metadata"` + Relationships Relationships `json:"relationships"` +} +type SpaceLinks struct { + Self *Link `json:"self"` + Organization *Link `json:"organization"` +} + +func ForCreateOrg(org repositories.OrgRecord, apiBaseURL url.URL) OrgResponse { + return toOrgResponse(org, apiBaseURL) +} + +func ForOrgList(orgs []repositories.OrgRecord, apiBaseURL url.URL) OrgListResponse { orgResponses := []OrgResponse{} + for _, org := range orgs { - selfLink.Path = path.Join(orgsBase, org.GUID) - orgResponses = append(orgResponses, OrgResponse{ - Name: org.Name, - GUID: org.GUID, - CreatedAt: org.CreatedAt.UTC().Format(time.RFC3339), - UpdatedAt: org.CreatedAt.UTC().Format(time.RFC3339), + orgResponses = append(orgResponses, toOrgResponse(org, apiBaseURL)) + } + + return OrgListResponse{ + Pagination: PaginationData{ + TotalResults: len(orgs), + TotalPages: 1, + First: PageRef{ + HREF: buildURL(apiBaseURL).appendPath(orgsBase).setQuery("page=1").build(), + }, + Last: PageRef{ + HREF: buildURL(apiBaseURL).appendPath(orgsBase).setQuery("page=1").build(), + }, + }, + Resources: orgResponses, + } +} + +func ForSpaceList(spaces []repositories.SpaceRecord, apiBaseURL url.URL) SpaceListResponse { + spaceResponses := []SpaceResponse{} + + for _, space := range spaces { + spaceResponses = append(spaceResponses, SpaceResponse{ + Name: space.Name, + GUID: space.GUID, + CreatedAt: space.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: space.CreatedAt.UTC().Format(time.RFC3339), Metadata: Metadata{ Labels: map[string]string{}, Annotations: map[string]string{}, }, - Relationships: Relationships{}, - Links: OrgLinks{ + Relationships: Relationships{ + "organization": Relationship{ + Data: RelationshipData{ + GUID: space.OrganizationGUID, + }, + }, + }, + Links: SpaceLinks{ Self: &Link{ - HREF: selfLink.String(), + HREF: buildURL(apiBaseURL).appendPath(spacesBase, space.GUID).build(), + }, + Organization: &Link{ + HREF: buildURL(apiBaseURL).appendPath(orgsBase, space.OrganizationGUID).build(), }, }, }) } - return OrgListResponse{ + paginationURL := buildURL(apiBaseURL).appendPath(spacesBase).setQuery("page=1").build() + return SpaceListResponse{ Pagination: PaginationData{ - TotalResults: len(orgs), + TotalResults: len(spaces), TotalPages: 1, First: PageRef{ - HREF: prefixedLinkURL(apiBaseURL, "v3/organizations?page=1"), + HREF: paginationURL, }, Last: PageRef{ - HREF: prefixedLinkURL(apiBaseURL, "v3/organizations?page=1"), + HREF: paginationURL, }, }, - Resources: orgResponses, + Resources: spaceResponses, + } +} + +func toOrgResponse(org repositories.OrgRecord, apiBaseURL url.URL) OrgResponse { + return OrgResponse{ + Name: org.Name, + GUID: org.GUID, + CreatedAt: org.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: org.CreatedAt.UTC().Format(time.RFC3339), + Suspended: org.Suspended, + Metadata: Metadata{ + Labels: orEmptyMap(org.Labels), + Annotations: orEmptyMap(org.Annotations), + }, + Relationships: Relationships{}, + Links: OrgLinks{ + Self: &Link{ + HREF: buildURL(apiBaseURL).appendPath(orgsBase, org.GUID).build(), + }, + }, + } +} + +func orEmptyMap(m map[string]string) map[string]string { + if m == nil { + return map[string]string{} } + return m } diff --git a/presenter/package.go b/presenter/package.go index bd64cd0..8e756d7 100644 --- a/presenter/package.go +++ b/presenter/package.go @@ -1,11 +1,15 @@ package presenter import ( - "fmt" + "net/url" "code.cloudfoundry.org/cf-k8s-api/repositories" ) +const ( + packagesBase = "/v3/packages" +) + type PackageResponse struct { GUID string `json:"guid"` Type string `json:"type"` @@ -18,8 +22,7 @@ type PackageResponse struct { UpdatedAt string `json:"updated_at"` } -type PackageData struct { -} +type PackageData struct{} type PackageLinks struct { Self Link `json:"self"` @@ -28,7 +31,7 @@ type PackageLinks struct { App Link `json:"app"` } -func ForPackage(record repositories.PackageRecord, baseURL string) PackageResponse { +func ForPackage(record repositories.PackageRecord, baseURL url.URL) PackageResponse { return PackageResponse{ GUID: record.GUID, Type: record.Type, @@ -44,18 +47,18 @@ func ForPackage(record repositories.PackageRecord, baseURL string) PackageRespon }, Links: PackageLinks{ Self: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/packages/%s", record.GUID)), + HREF: buildURL(baseURL).appendPath(packagesBase, record.GUID).build(), }, Upload: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/packages/%s/upload", record.GUID)), + HREF: buildURL(baseURL).appendPath(packagesBase, record.GUID, "upload").build(), Method: "POST", }, Download: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/packages/%s/download", record.GUID)), + HREF: buildURL(baseURL).appendPath(packagesBase, record.GUID, "download").build(), Method: "GET", }, App: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/apps/%s", record.AppGUID)), + HREF: buildURL(baseURL).appendPath(appsBase, record.AppGUID).build(), }, }, Metadata: Metadata{ diff --git a/presenter/route.go b/presenter/route.go index e1f7190..840a50b 100644 --- a/presenter/route.go +++ b/presenter/route.go @@ -2,10 +2,16 @@ package presenter import ( "fmt" + "net/url" "code.cloudfoundry.org/cf-k8s-api/repositories" ) +const ( + routesBase = "/v3/routes" + domainsBase = "/v3/domains" +) + type RouteResponse struct { GUID string `json:"guid"` Protocol string `json:"protocol"` @@ -42,13 +48,13 @@ type routeLinks struct { Destinations Link `json:"destinations"` } -func ForRoute(route repositories.RouteRecord, baseURL string) RouteResponse { +func ForRoute(route repositories.RouteRecord, baseURL url.URL) RouteResponse { return RouteResponse{ GUID: route.GUID, Protocol: route.Protocol, Host: route.Host, Path: route.Path, - URL: url(route), + URL: routeURL(route), CreatedAt: route.CreatedAt, UpdatedAt: route.UpdatedAt, Relationships: Relationships{ @@ -69,22 +75,22 @@ func ForRoute(route repositories.RouteRecord, baseURL string) RouteResponse { }, Links: routeLinks{ Self: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/routes/%s", route.GUID)), + HREF: buildURL(baseURL).appendPath(routesBase, route.GUID).build(), }, Space: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/spaces/%s", route.SpaceGUID)), + HREF: buildURL(baseURL).appendPath(spacesBase, route.SpaceGUID).build(), }, Domain: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/domains/%s", route.DomainRef.GUID)), + HREF: buildURL(baseURL).appendPath(domainsBase, route.DomainRef.GUID).build(), }, Destinations: Link{ - HREF: prefixedLinkURL(baseURL, fmt.Sprintf("v3/routes/%s/destinations", route.GUID)), + HREF: buildURL(baseURL).appendPath(routesBase, route.GUID, "destinations").build(), }, }, } } -func url(route repositories.RouteRecord) string { +func routeURL(route repositories.RouteRecord) string { if route.Host != "" { return fmt.Sprintf("%s.%s%s", route.Host, route.DomainRef.Name, route.Path) } else { diff --git a/presenter/shared.go b/presenter/shared.go index d79427c..1ae7557 100644 --- a/presenter/shared.go +++ b/presenter/shared.go @@ -1,7 +1,8 @@ package presenter import ( - "fmt" + "net/url" + "path" ) type Lifecycle struct { @@ -47,6 +48,28 @@ type PageRef struct { HREF string `json:"href"` } -func prefixedLinkURL(baseURL, path string) string { - return fmt.Sprintf("%s/%s", baseURL, path) +type buildURL url.URL + +func (u buildURL) appendPath(subpath ...string) buildURL { + rest := path.Join(subpath...) + if u.Path == "" { + u.Path = rest + } else { + u.Path = path.Join(u.Path, rest) + } + + return u +} + +func (u buildURL) setQuery(rawQuery string) buildURL { + u.RawQuery = rawQuery + + return u +} + +func (u buildURL) build() string { + native := url.URL(u) + nativeP := &native + + return nativeP.String() } diff --git a/repositories/app_repository.go b/repositories/app_repository.go index 92578d5..b5cb99c 100644 --- a/repositories/app_repository.go +++ b/repositories/app_repository.go @@ -59,11 +59,6 @@ type LifecycleData struct { Stack string } -type SpaceRecord struct { - Name string - OrganizationGUID string -} - type AppEnvVarsRecord struct { Name string AppGUID string @@ -240,7 +235,7 @@ func filterAppsByMetadataName(apps []workloadsv1alpha1.CFApp, name string) []wor } func v1NamespaceToSpaceRecord(namespace *v1.Namespace) SpaceRecord { - //TODO How do we derive Organization GUID here? + // TODO How do we derive Organization GUID here? return SpaceRecord{ Name: namespace.Name, OrganizationGUID: "", diff --git a/repositories/org_repository.go b/repositories/org_repository.go index eb61397..0016194 100644 --- a/repositories/org_repository.go +++ b/repositories/org_repository.go @@ -4,24 +4,36 @@ import ( "context" "time" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/selection" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/hierarchical-namespaces/api/v1alpha2" ) -//+kubebuilder:rbac:groups=hnc.x-k8s.io,resources=subnamespaceanchors,verbs=list +//+kubebuilder:rbac:groups=hnc.x-k8s.io,resources=subnamespaceanchors,verbs=list;create const ( - OrgNameLabel = "cloudfoundry.org/org-name" + OrgNameLabel = "cloudfoundry.org/org-name" + SpaceNameLabel = "cloudfoundry.org/space-name" ) type OrgRecord struct { - Name string - GUID string - Suspended bool - CreatedAt time.Time - UpdatedAt time.Time + Name string + GUID string + Suspended bool + Labels map[string]string + Annotations map[string]string + CreatedAt time.Time + UpdatedAt time.Time +} + +type SpaceRecord struct { + Name string + GUID string + OrganizationGUID string + CreatedAt time.Time + UpdatedAt time.Time } type OrgRepo struct { @@ -36,6 +48,27 @@ func NewOrgRepo(rootNamespace string, privilegedClient client.Client) *OrgRepo { } } +func (r *OrgRepo) CreateOrg(ctx context.Context, org OrgRecord) (OrgRecord, error) { + anchor := &v1alpha2.SubnamespaceAnchor{ + ObjectMeta: metav1.ObjectMeta{ + Name: org.GUID, + Namespace: r.rootNamespace, + Labels: map[string]string{ + OrgNameLabel: org.Name, + }, + }, + } + err := r.privilegedClient.Create(ctx, anchor) + if err != nil { + return OrgRecord{}, err + } + + org.GUID = anchor.Name + org.CreatedAt = anchor.CreationTimestamp.Time + org.UpdatedAt = anchor.CreationTimestamp.Time + return org, nil +} + func (r *OrgRepo) FetchOrgs(ctx context.Context, names []string) ([]OrgRecord, error) { subnamespaceAnchorList := &v1alpha2.SubnamespaceAnchorList{} @@ -60,7 +93,7 @@ func (r *OrgRepo) FetchOrgs(ctx context.Context, names []string) ([]OrgRecord, e for _, anchor := range subnamespaceAnchorList.Items { records = append(records, OrgRecord{ Name: anchor.Labels[OrgNameLabel], - GUID: string(anchor.UID), + GUID: anchor.Name, CreatedAt: anchor.CreationTimestamp.Time, UpdatedAt: anchor.CreationTimestamp.Time, }) @@ -68,3 +101,67 @@ func (r *OrgRepo) FetchOrgs(ctx context.Context, names []string) ([]OrgRecord, e return records, nil } + +func (r *OrgRepo) FetchSpaces(ctx context.Context, organizationGUIDs, names []string) ([]SpaceRecord, error) { + subnamespaceAnchorList := &v1alpha2.SubnamespaceAnchorList{} + + err := r.privilegedClient.List(ctx, subnamespaceAnchorList) + if err != nil { + return nil, err + } + + orgsFilter := toMap(organizationGUIDs) + orgUIDs := map[string]struct{}{} + for _, anchor := range subnamespaceAnchorList.Items { + if anchor.Namespace != r.rootNamespace { + continue + } + + if !matchFilter(orgsFilter, anchor.Name) { + continue + } + + orgUIDs[anchor.Name] = struct{}{} + } + + nameFilter := toMap(names) + records := []SpaceRecord{} + for _, anchor := range subnamespaceAnchorList.Items { + spaceName := anchor.Labels[SpaceNameLabel] + if !matchFilter(nameFilter, spaceName) { + continue + } + + if _, ok := orgUIDs[anchor.Namespace]; !ok { + continue + } + + records = append(records, SpaceRecord{ + Name: spaceName, + GUID: anchor.Name, + OrganizationGUID: anchor.Namespace, + CreatedAt: anchor.CreationTimestamp.Time, + UpdatedAt: anchor.CreationTimestamp.Time, + }) + } + + return records, nil +} + +func matchFilter(filter map[string]struct{}, value string) bool { + if len(filter) == 0 { + return true + } + + _, ok := filter[value] + return ok +} + +func toMap(elements []string) map[string]struct{} { + result := map[string]struct{}{} + for _, element := range elements { + result[element] = struct{}{} + } + + return result +} diff --git a/repositories/org_repository_test.go b/repositories/org_repository_test.go index d4b37cf..74c0075 100644 --- a/repositories/org_repository_test.go +++ b/repositories/org_repository_test.go @@ -1,58 +1,128 @@ package repositories_test import ( - "code.cloudfoundry.org/cf-k8s-api/repositories" "context" + "time" + + "code.cloudfoundry.org/cf-k8s-api/repositories" + + "github.com/google/uuid" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gstruct" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" + "sigs.k8s.io/controller-runtime/pkg/client" hnsv1alpha2 "sigs.k8s.io/hierarchical-namespaces/api/v1alpha2" ) var _ = Describe("OrgRepository", func() { - Describe("ListOrgs", func() { - var ( - orgRepo *repositories.OrgRepo - ctx context.Context - rootNamespace string + var ( + rootNamespace string + orgRepo *repositories.OrgRepo + ) - org1Ns, org2Ns, org3Ns *hnsv1alpha2.SubnamespaceAnchor - ) + BeforeEach(func() { + rootNamespace = generateGUID() + Expect(k8sClient.Create(context.Background(), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: rootNamespace}})).To(Succeed()) + orgRepo = repositories.NewOrgRepo(rootNamespace, k8sClient) + }) + + Describe("Create Org", func() { + var ctx context.Context BeforeEach(func() { - rootNamespace = generateGUID() - Expect(k8sClient.Create(context.Background(), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: rootNamespace}})).To(Succeed()) + ctx = context.Background() + }) - orgRepo = repositories.NewOrgRepo(rootNamespace, k8sClient) + It("creates a subnamespace anchor in the root namespace", func() { + org, err := orgRepo.CreateOrg(ctx, repositories.OrgRecord{ + GUID: "some-guid", + Name: "our-org", + }) + Expect(err).NotTo(HaveOccurred()) - ctx = context.Background() + namesRequirement, err := labels.NewRequirement(repositories.OrgNameLabel, selection.Equals, []string{"our-org"}) + Expect(err).NotTo(HaveOccurred()) + anchorList := hnsv1alpha2.SubnamespaceAnchorList{} + err = k8sClient.List(ctx, &anchorList, client.InNamespace(rootNamespace), client.MatchingLabelsSelector{ + Selector: labels.NewSelector().Add(*namesRequirement), + }) + Expect(err).NotTo(HaveOccurred()) + Expect(anchorList.Items).To(HaveLen(1)) - org1Ns = &hnsv1alpha2.SubnamespaceAnchor{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "org1", - Namespace: rootNamespace, - Labels: map[string]string{repositories.OrgNameLabel: "org1"}, - }, - } - org2Ns = &hnsv1alpha2.SubnamespaceAnchor{ + Expect(org.Name).To(Equal("our-org")) + Expect(org.GUID).To(Equal("some-guid")) + Expect(org.CreatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + Expect(org.UpdatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + }) + + When("the client fails to create the org", func() { + It("returns an error", func() { + _, err := orgRepo.CreateOrg(ctx, repositories.OrgRecord{ + Name: "this-string-has-illegal-characters-ц", + }) + Expect(err).To(HaveOccurred()) + }) + }) + }) + + Describe("ListOrgs", func() { + var ( + ctx context.Context + + org1Anchor, org2Anchor, org3Anchor *hnsv1alpha2.SubnamespaceAnchor + space11Anchor, space12Anchor, space21Anchor, space22Anchor, space31Anchor, space32Anchor *hnsv1alpha2.SubnamespaceAnchor + ) + + createOrgAnchor := func(name string) *hnsv1alpha2.SubnamespaceAnchor { + guid := uuid.New().String() + org := &hnsv1alpha2.SubnamespaceAnchor{ ObjectMeta: metav1.ObjectMeta{ - GenerateName: "org2", - Namespace: rootNamespace, - Labels: map[string]string{repositories.OrgNameLabel: "org2"}, + Name: guid, + Namespace: rootNamespace, + Labels: map[string]string{repositories.OrgNameLabel: name}, }, } - org3Ns = &hnsv1alpha2.SubnamespaceAnchor{ + + Expect(k8sClient.Create(ctx, org)).To(Succeed()) + Expect(k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: org.Name}})).To(Succeed()) + + return org + } + + createSpaceAnchor := func(name, orgName string) *hnsv1alpha2.SubnamespaceAnchor { + guid := uuid.New().String() + space := &hnsv1alpha2.SubnamespaceAnchor{ ObjectMeta: metav1.ObjectMeta{ - GenerateName: "org3", - Namespace: rootNamespace, - Labels: map[string]string{repositories.OrgNameLabel: "org3"}, + Name: guid, + Namespace: orgName, + Labels: map[string]string{repositories.SpaceNameLabel: name}, }, } - Expect(k8sClient.Create(ctx, org1Ns)).To(Succeed()) - Expect(k8sClient.Create(ctx, org2Ns)).To(Succeed()) - Expect(k8sClient.Create(ctx, org3Ns)).To(Succeed()) + Expect(k8sClient.Create(ctx, space)).To(Succeed()) + + return space + } + + BeforeEach(func() { + ctx = context.Background() + + org1Anchor = createOrgAnchor("org1") + org2Anchor = createOrgAnchor("org2") + org3Anchor = createOrgAnchor("org3") + + space11Anchor = createSpaceAnchor("space1", org1Anchor.Name) + space12Anchor = createSpaceAnchor("space2", org1Anchor.Name) + + space21Anchor = createSpaceAnchor("space1", org2Anchor.Name) + space22Anchor = createSpaceAnchor("space3", org2Anchor.Name) + + space31Anchor = createSpaceAnchor("space1", org3Anchor.Name) + space32Anchor = createSpaceAnchor("space4", org3Anchor.Name) }) It("returns the 3 orgs", func() { @@ -62,21 +132,21 @@ var _ = Describe("OrgRepository", func() { Expect(orgs).To(ConsistOf( repositories.OrgRecord{ Name: "org1", - CreatedAt: org1Ns.CreationTimestamp.Time, - UpdatedAt: org1Ns.CreationTimestamp.Time, - GUID: string(org1Ns.UID), + CreatedAt: org1Anchor.CreationTimestamp.Time, + UpdatedAt: org1Anchor.CreationTimestamp.Time, + GUID: org1Anchor.Name, }, repositories.OrgRecord{ Name: "org2", - CreatedAt: org2Ns.CreationTimestamp.Time, - UpdatedAt: org2Ns.CreationTimestamp.Time, - GUID: string(org2Ns.UID), + CreatedAt: org2Anchor.CreationTimestamp.Time, + UpdatedAt: org2Anchor.CreationTimestamp.Time, + GUID: org2Anchor.Name, }, repositories.OrgRecord{ Name: "org3", - CreatedAt: org3Ns.CreationTimestamp.Time, - UpdatedAt: org3Ns.CreationTimestamp.Time, - GUID: string(org3Ns.UID), + CreatedAt: org3Anchor.CreationTimestamp.Time, + UpdatedAt: org3Anchor.CreationTimestamp.Time, + GUID: org3Anchor.Name, }, )) }) @@ -89,18 +159,143 @@ var _ = Describe("OrgRepository", func() { Expect(orgs).To(ConsistOf( repositories.OrgRecord{ Name: "org1", - CreatedAt: org1Ns.CreationTimestamp.Time, - UpdatedAt: org1Ns.CreationTimestamp.Time, - GUID: string(org1Ns.UID), + CreatedAt: org1Anchor.CreationTimestamp.Time, + UpdatedAt: org1Anchor.CreationTimestamp.Time, + GUID: org1Anchor.Name, }, repositories.OrgRecord{ Name: "org3", - CreatedAt: org3Ns.CreationTimestamp.Time, - UpdatedAt: org3Ns.CreationTimestamp.Time, - GUID: string(org3Ns.UID), + CreatedAt: org3Anchor.CreationTimestamp.Time, + UpdatedAt: org3Anchor.CreationTimestamp.Time, + GUID: org3Anchor.Name, }, )) }) }) + + It("returns the 6 spaces", func() { + spaces, err := orgRepo.FetchSpaces(ctx, []string{}, []string{}) + Expect(err).NotTo(HaveOccurred()) + + Expect(spaces).To(ConsistOf( + repositories.SpaceRecord{ + Name: "space1", + CreatedAt: space11Anchor.CreationTimestamp.Time, + UpdatedAt: space11Anchor.CreationTimestamp.Time, + GUID: space11Anchor.Name, + OrganizationGUID: org1Anchor.Name, + }, + repositories.SpaceRecord{ + Name: "space2", + CreatedAt: space12Anchor.CreationTimestamp.Time, + UpdatedAt: space12Anchor.CreationTimestamp.Time, + GUID: space12Anchor.Name, + OrganizationGUID: org1Anchor.Name, + }, + repositories.SpaceRecord{ + Name: "space1", + CreatedAt: space21Anchor.CreationTimestamp.Time, + UpdatedAt: space21Anchor.CreationTimestamp.Time, + GUID: space21Anchor.Name, + OrganizationGUID: org2Anchor.Name, + }, + repositories.SpaceRecord{ + Name: "space3", + CreatedAt: space22Anchor.CreationTimestamp.Time, + UpdatedAt: space22Anchor.CreationTimestamp.Time, + GUID: space22Anchor.Name, + OrganizationGUID: org2Anchor.Name, + }, + repositories.SpaceRecord{ + Name: "space1", + CreatedAt: space31Anchor.CreationTimestamp.Time, + UpdatedAt: space31Anchor.CreationTimestamp.Time, + GUID: space31Anchor.Name, + OrganizationGUID: org3Anchor.Name, + }, + repositories.SpaceRecord{ + Name: "space4", + CreatedAt: space32Anchor.CreationTimestamp.Time, + UpdatedAt: space32Anchor.CreationTimestamp.Time, + GUID: space32Anchor.Name, + OrganizationGUID: org3Anchor.Name, + }, + )) + }) + + When("filtering by org guids", func() { + It("only retruns the spaces belonging to the specified org guids", func() { + spaces, err := orgRepo.FetchSpaces(ctx, []string{string(org1Anchor.Name), string(org3Anchor.Name), "does-not-exist"}, []string{}) + Expect(err).NotTo(HaveOccurred()) + Expect(spaces).To(ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Name": Equal("space1"), + "OrganizationGUID": Equal(string(org1Anchor.Name)), + }), + MatchFields(IgnoreExtras, Fields{ + "Name": Equal("space1"), + "OrganizationGUID": Equal(string(org3Anchor.Name)), + }), + MatchFields(IgnoreExtras, Fields{"Name": Equal("space2")}), + MatchFields(IgnoreExtras, Fields{"Name": Equal("space4")}), + )) + }) + }) + + When("filtering by space names", func() { + It("only retruns the spaces matching the specified names", func() { + spaces, err := orgRepo.FetchSpaces(ctx, []string{}, []string{"space1", "space3", "does-not-exist"}) + Expect(err).NotTo(HaveOccurred()) + Expect(spaces).To(ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Name": Equal("space1"), + "OrganizationGUID": Equal(string(org1Anchor.Name)), + }), + MatchFields(IgnoreExtras, Fields{ + "Name": Equal("space1"), + "OrganizationGUID": Equal(string(org2Anchor.Name)), + }), + MatchFields(IgnoreExtras, Fields{ + "Name": Equal("space1"), + "OrganizationGUID": Equal(string(org3Anchor.Name)), + }), + MatchFields(IgnoreExtras, Fields{"Name": Equal("space3")}), + )) + }) + }) + + When("filtering by org guids and space names", func() { + It("only retruns the spaces matching the specified names", func() { + spaces, err := orgRepo.FetchSpaces(ctx, []string{string(org1Anchor.Name), string(org2Anchor.Name)}, []string{"space1", "space2", "space4"}) + Expect(err).NotTo(HaveOccurred()) + Expect(spaces).To(ConsistOf( + MatchFields(IgnoreExtras, Fields{ + "Name": Equal("space1"), + "OrganizationGUID": Equal(string(org1Anchor.Name)), + }), + MatchFields(IgnoreExtras, Fields{ + "Name": Equal("space1"), + "OrganizationGUID": Equal(string(org2Anchor.Name)), + }), + MatchFields(IgnoreExtras, Fields{"Name": Equal("space2")}), + )) + }) + }) + + When("filtering by space names that don't exist", func() { + It("only retruns the spaces matching the specified names", func() { + spaces, err := orgRepo.FetchSpaces(ctx, []string{}, []string{"does-not-exist", "still-does-not-exist"}) + Expect(err).NotTo(HaveOccurred()) + Expect(spaces).To(BeEmpty()) + }) + }) + + When("filtering by org uids that don't exist", func() { + It("only retruns the spaces matching the specified names", func() { + spaces, err := orgRepo.FetchSpaces(ctx, []string{"does-not-exist", "still-does-not-exist"}, []string{}) + Expect(err).NotTo(HaveOccurred()) + Expect(spaces).To(BeEmpty()) + }) + }) }) }) diff --git a/tests/e2e/e2e_suite_test.go b/tests/e2e/e2e_suite_test.go index 11b8851..b0e0613 100644 --- a/tests/e2e/e2e_suite_test.go +++ b/tests/e2e/e2e_suite_test.go @@ -4,22 +4,35 @@ package e2e_test import ( + "context" + "fmt" "net/http" "os" "testing" + "time" + "code.cloudfoundry.org/cf-k8s-api/repositories" "github.com/hashicorp/go-uuid" "github.com/matt-royal/biloba" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/scheme" controllerruntime "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" - hncv1alpha2 "sigs.k8s.io/hierarchical-namespaces/api/v1alpha2" + hnsv1alpha2 "sigs.k8s.io/hierarchical-namespaces/api/v1alpha2" ) +type hierarchicalNamespace struct { + label string + createdAt string + guid string + children []hierarchicalNamespace +} + var ( testServerAddress string k8sClient client.Client @@ -37,7 +50,7 @@ var _ = BeforeSuite(func() { logf.SetLogger(zap.New(zap.WriteTo(os.Stderr), zap.UseDevMode(true))) - hncv1alpha2.AddToScheme(scheme.Scheme) + hnsv1alpha2.AddToScheme(scheme.Scheme) config, err := controllerruntime.GetConfig() Expect(err).NotTo(HaveOccurred()) @@ -69,9 +82,69 @@ func ensureServerIsUp() { }, "30s").Should(Equal(http.StatusOK), "API Server at %s was not running after 30 seconds", apiServerRoot) } -func generateGUID() string { +func generateGUID(prefix string) string { guid, err := uuid.GenerateUUID() Expect(err).NotTo(HaveOccurred()) - return guid[:30] + return fmt.Sprintf("%s-%s", prefix, guid[:6]) +} + +func waitForSubnamespaceAnchor(parent, name string) { + Eventually(func() (bool, error) { + anchor := &hnsv1alpha2.SubnamespaceAnchor{} + err := k8sClient.Get(context.Background(), client.ObjectKey{Namespace: parent, Name: name}, anchor) + if err != nil { + return false, err + } + + return anchor.Status.State == hnsv1alpha2.Ok, nil + }, "30s").Should(BeTrue()) +} + +func waitForNamespaceDeletion(parent, ns string) { + Eventually(func() (bool, error) { + err := k8sClient.Get(context.Background(), client.ObjectKey{Namespace: parent, Name: ns}, &hnsv1alpha2.SubnamespaceAnchor{}) + if errors.IsNotFound(err) { + return true, nil + } + + return false, err + }, "30s").Should(BeTrue()) +} + +func createHierarchicalNamespace(parentName, cfName, labelKey string) hierarchicalNamespace { + ctx := context.Background() + + anchor := &hnsv1alpha2.SubnamespaceAnchor{} + anchor.GenerateName = cfName + anchor.Namespace = parentName + anchor.Labels = map[string]string{labelKey: cfName} + err := k8sClient.Create(ctx, anchor) + Expect(err).NotTo(HaveOccurred()) + + return hierarchicalNamespace{ + label: cfName, + guid: anchor.Name, + createdAt: anchor.CreationTimestamp.Time.UTC().Format(time.RFC3339), + } +} + +func deleteSubnamespace(parent, name string) { + ctx := context.Background() + + anchor := hnsv1alpha2.SubnamespaceAnchor{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: parent, + Name: name, + }, + } + err := k8sClient.Delete(ctx, &anchor) + Expect(err).NotTo(HaveOccurred()) +} + +func deleteSubnamespaceByLabel(parentNS, label string) { + ctx := context.Background() + + err := k8sClient.DeleteAllOf(ctx, &hnsv1alpha2.SubnamespaceAnchor{}, client.MatchingLabels{repositories.OrgNameLabel: label}, client.InNamespace(parentNS)) + Expect(err).NotTo(HaveOccurred()) } diff --git a/tests/e2e/orgs_test.go b/tests/e2e/orgs_test.go index e9e7395..5cbeedb 100644 --- a/tests/e2e/orgs_test.go +++ b/tests/e2e/orgs_test.go @@ -4,64 +4,94 @@ package e2e_test import ( - "context" "encoding/json" "fmt" "io/ioutil" "net/http" + "strconv" "strings" . "github.com/onsi/gomega/gstruct" "code.cloudfoundry.org/cf-k8s-api/presenter" "code.cloudfoundry.org/cf-k8s-api/repositories" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/selection" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" - "sigs.k8s.io/controller-runtime/pkg/client" - hnsv1alpha2 "sigs.k8s.io/hierarchical-namespaces/api/v1alpha2" ) var _ = Describe("Orgs", func() { - Describe("Listing Orgs", func() { - var org1, org2, org3 string + Describe("creating orgs", func() { + var orgName string BeforeEach(func() { - org1 = generateGUID() - org2 = generateGUID() - org3 = generateGUID() - createSubnamespaces(rootNamespace, org1, org2, org3) + orgName = generateGUID("org") }) AfterEach(func() { - deleteSubnamespaces(rootNamespace, org1, org2, org3) + deleteSubnamespaceByLabel(rootNamespace, orgName) + }) + + It("creates an org", func() { + orgsUrl := apiServerRoot + "/v3/organizations" + + body := fmt.Sprintf(`{ "name": "%s" }`, orgName) + req, err := http.NewRequest(http.MethodPost, orgsUrl, strings.NewReader(body)) + Expect(err).NotTo(HaveOccurred()) + + resp, err := http.DefaultClient.Do(req) + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + + Expect(resp.StatusCode).To(Equal(http.StatusCreated)) + Expect(resp.Header["Content-Type"]).To(ConsistOf("application/json")) + responseMap := map[string]interface{}{} + Expect(json.NewDecoder(resp.Body).Decode(&responseMap)).To(Succeed()) + Expect(responseMap["name"]).To(Equal(orgName)) + }) + }) + + Describe("listing orgs", func() { + var orgs []hierarchicalNamespace + + BeforeEach(func() { + orgs = []hierarchicalNamespace{} + for i := 1; i < 4; i++ { + orgDetails := createHierarchicalNamespace(rootNamespace, generateGUID("org"+strconv.Itoa(i)), repositories.OrgNameLabel) + orgs = append(orgs, orgDetails) + waitForSubnamespaceAnchor(rootNamespace, orgDetails.guid) + } + }) + + AfterEach(func() { + for _, org := range orgs { + deleteSubnamespace(rootNamespace, org.guid) + } }) It("returns all 3 orgs", func() { - Eventually(getOrgs()).Should(ContainElements( - MatchFields(IgnoreExtras, Fields{"Name": Equal(org1)}), - MatchFields(IgnoreExtras, Fields{"Name": Equal(org2)}), - MatchFields(IgnoreExtras, Fields{"Name": Equal(org3)}), + Eventually(getOrgsFn()).Should(ContainElements( + MatchFields(IgnoreExtras, Fields{"Name": Equal(orgs[0].label)}), + MatchFields(IgnoreExtras, Fields{"Name": Equal(orgs[1].label)}), + MatchFields(IgnoreExtras, Fields{"Name": Equal(orgs[2].label)}), )) }) When("org names are filtered", func() { It("returns orgs 1 & 3", func() { - Eventually(getOrgs(org1, org3)).Should(ContainElements( - MatchFields(IgnoreExtras, Fields{"Name": Equal(org1)}), - MatchFields(IgnoreExtras, Fields{"Name": Equal(org3)}), + Eventually(getOrgsFn(orgs[0].label, orgs[2].label)).Should(ContainElements( + MatchFields(IgnoreExtras, Fields{"Name": Equal(orgs[0].label)}), + MatchFields(IgnoreExtras, Fields{"Name": Equal(orgs[2].label)}), )) - Consistently(getOrgs(org1, org3), "2s").ShouldNot(ContainElement( - MatchFields(IgnoreExtras, Fields{"Name": Equal(org2)}), + Consistently(getOrgsFn(orgs[0].label, orgs[2].label), "2s").ShouldNot(ContainElement( + MatchFields(IgnoreExtras, Fields{"Name": Equal(orgs[1].label)}), )) }) }) }) }) -func getOrgs(names ...string) func() ([]presenter.OrgResponse, error) { +func getOrgsFn(names ...string) func() ([]presenter.OrgResponse, error) { return func() ([]presenter.OrgResponse, error) { orgsUrl := apiServerRoot + "/v3/organizations" @@ -99,29 +129,3 @@ func getOrgs(names ...string) func() ([]presenter.OrgResponse, error) { return orgList.Resources, nil } } - -func createSubnamespaces(parent string, names ...string) { - ctx := context.Background() - - for _, name := range names { - anchor := &hnsv1alpha2.SubnamespaceAnchor{} - anchor.GenerateName = name - anchor.Namespace = parent - anchor.Labels = map[string]string{repositories.OrgNameLabel: name} - err := k8sClient.Create(ctx, anchor) - Expect(err).NotTo(HaveOccurred()) - } -} - -func deleteSubnamespaces(parent string, names ...string) { - ctx := context.Background() - - namesRequirement, err := labels.NewRequirement(repositories.OrgNameLabel, selection.In, names) - Expect(err).NotTo(HaveOccurred()) - namesSelector := client.MatchingLabelsSelector{ - Selector: labels.NewSelector().Add(*namesRequirement), - } - - err = k8sClient.DeleteAllOf(ctx, &hnsv1alpha2.SubnamespaceAnchor{}, client.InNamespace(parent), namesSelector) - Expect(err).NotTo(HaveOccurred()) -} diff --git a/tests/e2e/spaces_test.go b/tests/e2e/spaces_test.go new file mode 100644 index 0000000..ccce11b --- /dev/null +++ b/tests/e2e/spaces_test.go @@ -0,0 +1,117 @@ +//go:build e2e +// +build e2e + +package e2e_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strconv" + + "code.cloudfoundry.org/cf-k8s-api/repositories" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +var _ = Describe("listing spaces", func() { + var orgs []hierarchicalNamespace + + BeforeEach(func() { + orgs = []hierarchicalNamespace{} + for i := 1; i <= 3; i++ { + orgDetails := createHierarchicalNamespace(rootNamespace, generateGUID("org"+strconv.Itoa(i)), repositories.OrgNameLabel) + waitForSubnamespaceAnchor(rootNamespace, orgDetails.guid) + + for j := 1; j <= 2; j++ { + spaceDetails := createHierarchicalNamespace(orgDetails.guid, generateGUID("space"+strconv.Itoa(j)), repositories.SpaceNameLabel) + waitForSubnamespaceAnchor(orgDetails.guid, spaceDetails.guid) + orgDetails.children = append(orgDetails.children, spaceDetails) + } + + orgs = append(orgs, orgDetails) + } + }) + + AfterEach(func() { + for _, org := range orgs { + for _, space := range org.children { + deleteSubnamespace(org.guid, space.guid) + waitForNamespaceDeletion(org.guid, space.guid) + } + deleteSubnamespace(rootNamespace, org.guid) + } + }) + + It("lists all the spaces", func() { + Eventually(getSpacesFn(), "60s").Should(SatisfyAll( + HaveKeyWithValue("pagination", HaveKeyWithValue("total_results", BeNumerically(">=", 6))), + HaveKeyWithValue("resources", ContainElements( + HaveKeyWithValue("name", orgs[0].children[0].label), + HaveKeyWithValue("name", orgs[0].children[1].label), + HaveKeyWithValue("name", orgs[1].children[0].label), + HaveKeyWithValue("name", orgs[1].children[1].label), + HaveKeyWithValue("name", orgs[2].children[0].label), + HaveKeyWithValue("name", orgs[2].children[1].label), + )))) + }) + + When("filtering by organization GUIDs", func() { + It("only lists spaces beloging to the orgs", func() { + Eventually(getSpacesWithQueryFn(map[string]string{"organization_guids": fmt.Sprintf("%s,%s", orgs[0].guid, orgs[2].guid)}), "60s").Should( + HaveKeyWithValue("resources", ConsistOf( + HaveKeyWithValue("name", orgs[0].children[0].label), + HaveKeyWithValue("name", orgs[0].children[1].label), + HaveKeyWithValue("name", orgs[2].children[0].label), + HaveKeyWithValue("name", orgs[2].children[1].label), + ))) + }) + }) +}) + +func getSpacesFn() func() (map[string]interface{}, error) { + return getSpacesWithQueryFn(nil) +} + +func getSpacesWithQueryFn(query map[string]string) func() (map[string]interface{}, error) { + return func() (map[string]interface{}, error) { + spacesUrl, err := url.Parse(apiServerRoot) + if err != nil { + return nil, err + } + + spacesUrl.Path = "/v3/spaces" + values := url.Values{} + for key, val := range query { + values.Set(key, val) + } + spacesUrl.RawQuery = values.Encode() + + resp, err := http.Get(spacesUrl.String()) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("bad status: %d", resp.StatusCode) + } + + bodyBytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + response := map[string]interface{}{} + err = json.Unmarshal(bodyBytes, &response) + if err != nil { + return nil, err + } + + return response, nil + } +}