From b25cecdef5e4712b656d16df6fcdcb90d7199614 Mon Sep 17 00:00:00 2001 From: ritmun Date: Wed, 1 Jul 2026 13:58:26 -0500 Subject: [PATCH] =?UTF-8?q?PLATENG-137:=20Go=20port=20of=20bonfire=5Flib?= =?UTF-8?q?=20=E2=80=94=20ephemeral=20namespace=20and=20cluster=20reservat?= =?UTF-8?q?ion=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds go/pkg/ephemeral, a Go library replacing bonfire_lib with no Python runtime or oc binary dependency. Supports namespace and cluster reservation lifecycle (reserve, release, extend, describe), pool listing, and status queries against cloud.redhat.com/v1alpha1 CRDs via client-go with three auth modes (token, in-cluster, kubeconfig). Includes 67 table-driven unit tests requiring no live cluster. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- go/AGENTS.md | 169 ++++++++++ go/go.mod | 48 +++ go/go.sum | 152 +++++++++ go/pkg/ephemeral/client_iface.go | 40 +++ go/pkg/ephemeral/clusters.go | 277 ++++++++++++++++ go/pkg/ephemeral/config.go | 51 +++ go/pkg/ephemeral/json.go | 74 +++++ go/pkg/ephemeral/k8s_client.go | 448 ++++++++++++++++++++++++++ go/pkg/ephemeral/mock_client_test.go | 161 +++++++++ go/pkg/ephemeral/pools.go | 149 +++++++++ go/pkg/ephemeral/pools_test.go | 142 ++++++++ go/pkg/ephemeral/reservations.go | 247 ++++++++++++++ go/pkg/ephemeral/reservations_test.go | 250 ++++++++++++++ go/pkg/ephemeral/status.go | 270 ++++++++++++++++ go/pkg/ephemeral/status_test.go | 389 ++++++++++++++++++++++ go/pkg/ephemeral/utils.go | 147 +++++++++ go/pkg/ephemeral/utils_test.go | 180 +++++++++++ 17 files changed, 3194 insertions(+) create mode 100644 go/AGENTS.md create mode 100644 go/go.mod create mode 100644 go/go.sum create mode 100644 go/pkg/ephemeral/client_iface.go create mode 100644 go/pkg/ephemeral/clusters.go create mode 100644 go/pkg/ephemeral/config.go create mode 100644 go/pkg/ephemeral/json.go create mode 100644 go/pkg/ephemeral/k8s_client.go create mode 100644 go/pkg/ephemeral/mock_client_test.go create mode 100644 go/pkg/ephemeral/pools.go create mode 100644 go/pkg/ephemeral/pools_test.go create mode 100644 go/pkg/ephemeral/reservations.go create mode 100644 go/pkg/ephemeral/reservations_test.go create mode 100644 go/pkg/ephemeral/status.go create mode 100644 go/pkg/ephemeral/status_test.go create mode 100644 go/pkg/ephemeral/utils.go create mode 100644 go/pkg/ephemeral/utils_test.go diff --git a/go/AGENTS.md b/go/AGENTS.md new file mode 100644 index 00000000..c5dd8ad4 --- /dev/null +++ b/go/AGENTS.md @@ -0,0 +1,169 @@ +# AGENTS.md — Go implementation + +Developer and agent instructions for the Go code in this directory. +For the Python codebase, see the root [AGENTS.md](../AGENTS.md). + +## Overview + +`go/pkg/ephemeral` is a Go port of `bonfire_lib` — the namespace and cluster reservation +lifecycle library. It speaks directly to `cloud.redhat.com/v1alpha1` CRDs via `client-go` +with no dependency on the `oc` binary or the Python runtime. + +This directory is a **staging area**. It is not the standard Go repo layout (the `go.mod` is +one level below the repo root). The intention is to move it to a dedicated repo once Phase 2 +(MCP server) is complete. + +## Package structure + +``` +go/ +├── go.mod # module: github.com/redhatinsights/bonfire, Go 1.24 +└── pkg/ + └── ephemeral/ + ├── client_iface.go # Client interface — all domain functions accept this + ├── k8s_client.go # EphemeralK8sClient: 3 auth modes, CRD + core v1 ops + ├── config.go # Settings struct + SettingsFromEnv() + ├── utils.go # FatalError, HMSToSeconds, DurationFmt, ValidateDNSName, ValidateTimeString + ├── json.go # nestedString/Int64/Map — nil-safe unstructured map traversal + ├── reservations.go # Reserve, Release, Extend + ├── clusters.go # ReserveCluster, ReleaseCluster, ExtendCluster, GetClusterStatus, GetKubeconfig + ├── pools.go # ListPools, ListClusterPools, GetPoolCapacity + ├── status.go # GetReservation, ListReservations, WaitOnReservation, DescribeNamespace + └── *_test.go # Table-driven tests; no live cluster required +``` + +## Development commands + +```bash +# All commands run from go/ (not the repo root — go.mod is here) + +# Run all tests +go test ./pkg/ephemeral/... -v + +# Build +go build ./... + +# Tidy dependencies +go mod tidy +``` + +## Design rules + +**Client interface over concrete type.** +All domain functions accept `Client` (defined in `client_iface.go`), not `*EphemeralK8sClient`. +`*EphemeralK8sClient` satisfies `Client` at compile time (enforced by `var _ Client = (*EphemeralK8sClient)(nil)`). +In tests, always inject `testClient` — never construct a real client. + +**No Jinja2 templates.** +CR bodies are built as `map[string]any` inline in `renderReservation` and `renderClusterReservation`. +The four `bonfire_lib/templates/*.yaml.j2` files have no Go equivalent; the struct replaces them. + +**Nil-safe map traversal.** +Unstructured CRs come back as `map[string]any`. Never use raw type assertions on nested keys. +Use `nestedString`, `nestedInt64`, `nestedMap` from `json.go` — they return zero values on +missing or nil keys without panicking. + +**Test doubles via function fields.** +Tests use `testClient` (in `mock_client_test.go`) — a struct of function fields, one per +`Client` method. `noopClient()` returns one with all fields stubbed to return zero values. +Override only the fields your test needs. No third-party mock library (gomock, testify) required. + +**Auth priority order** (matches Python `bonfire_mcp/auth.py`): +1. `K8S_SERVER` + `K8S_TOKEN` env vars set → token auth +2. `/var/run/secrets/kubernetes.io/serviceaccount/token` exists → in-cluster +3. `KUBECONFIG` env var or `~/.kube/config` → kubeconfig + +## Environment variables + +| Env var | Default | Purpose | +|---|---|---| +| `BONFIRE_DEFAULT_NAMESPACE_POOL` | `default` | Default pool for namespace reservations | +| `BONFIRE_DEFAULT_DURATION` | `1h` | Default reservation duration | +| `BONFIRE_NS_REQUESTER` | — | Override requester identity | +| `EPHEMERAL_ENV_NAME` | `insights-ephemeral` | Target environment name | +| `DEFAULT_BASE_NAMESPACE` | `ephemeral-base` | Fallback base namespace | +| `BONFIRE_BOT` | `false` | Suppress interactive behaviour | +| `K8S_SERVER` | — | API server URL (token auth) | +| `K8S_TOKEN` | — | Bearer token (token auth) | +| `K8S_CA_DATA` | — | Base64-encoded CA cert (token auth) | +| `K8S_SKIP_TLS_VERIFY` | `false` | Skip TLS verification | +| `KUBECONFIG` | `~/.kube/config` | Kubeconfig file path | + +## Common mistakes + +1. **Running `go` commands from the repo root.** The `go.mod` is in `go/`, not the repo root. + Always `cd go/` first, or use `go -C go/ test ./...`. + +2. **Direct type assertions on nested CR fields.** `res["status"].(map[string]any)["namespace"]` + panics if any key is missing or nil. Use `nestedString(res, "status", "namespace")` instead. + +3. **Constructing `EphemeralK8sClient` in tests.** It dials a real cluster. Use `noopClient()` + and override the function fields your test needs. + +4. **Forgetting that cluster reservations are non-blocking.** `ReserveCluster` returns immediately + with state `"waiting"`. The caller must poll `GetClusterStatus` until state is `"active"`. + `Reserve` (namespace) blocks and polls internally — the Go version uses a `time.Ticker` loop, + not `asyncio.to_thread`. + +5. **Import paths include `/go/`.** e.g. `github.com/redhatinsights/bonfire/go/pkg/ephemeral`. + This is a known awkwardness of the staging layout and will be fixed when the code moves to + its own repo. Do not add a `replace` directive to work around it. + +## Roadmap + +### Phase 1 — `pkg/ephemeral` library ✓ + +- [x] `k8s_client.go` — `EphemeralK8sClient`: DynamicClient wrapper, 3 auth modes, typed CRUD +- [x] `client_iface.go` — `Client` interface; compile-time check `EphemeralK8sClient` satisfies it +- [x] `reservations.go` — `Reserve()`, `Release()`, `Extend()` namespace reservation lifecycle +- [x] `clusters.go` — `ReserveCluster()`, `ReleaseCluster()`, `ExtendCluster()`, `GetKubeconfig()` +- [x] `pools.go` — `ListPools()`, `ListClusterPools()`, `ListAllPools()` +- [x] `status.go` — `GetReservation()`, `ListReservations()`, `WaitOnReservation()`, `DescribeNamespace()` +- [x] `config.go` — `Settings` dataclass loaded from env vars via `SettingsFromEnv()` +- [x] `utils.go` — `FatalError`, `ValidateDNSName()`, `HMSToSeconds()`, `DurationFmt()` +- [x] 67 table-driven tests, no live cluster required +- [x] Deliverable: `go/pkg/ephemeral` — importable Go library replacing `bonfire_lib` + +### Phase 2 — MCP server (`bonfire-mcp` Go binary) + +- [ ] Add `go/pkg/mcp/` — MCP server using `github.com/mark3labs/mcp-go` +- [ ] Port 8 tools from `bonfire_mcp/server.py`: `ephemeral_list_pools`, `ephemeral_reserve`, + `ephemeral_status`, `ephemeral_extend`, `ephemeral_release`, `ephemeral_list_reservations`, + `ephemeral_describe`, `ephemeral_get_kubeconfig` +- [ ] Port auth detection from `bonfire_mcp/auth.py` — already implemented in `k8s_client.go`, + wire up preflight check (401 → hard stop, 403 → warning, 404 → hard stop) +- [ ] Port formatters from `bonfire_mcp/formatters.py` — plain-text output for all tool results +- [ ] `cmd/bonfire-mcp/main.go` — stdio entry point (replaces `bonfire_mcp/__main__.py`) +- [ ] Port MCP server tests from `tests/test_bonfire_mcp/` +- [ ] Deliverable: single static binary `bonfire-mcp` with no Python or `oc` runtime dep + +### Phase 3 — CLI namespace/cluster commands + +- [ ] Add `cmd/bonfire/` — cobra CLI entry point +- [ ] Port `bonfire namespace reserve/release/extend/describe` — thin wrappers over `pkg/ephemeral` +- [ ] Port `bonfire pool list` and `bonfire cluster` commands +- [ ] Port config loading from `bonfire_lib/config.py` (already done as `Settings`) +- [ ] Deliverable: `bonfire` binary covering all non-deploy subcommands without `oc` + +### Phase 4 — Template processing / deploy pipeline + +Template processing uses `github.com/openshift/library-go` natively — no `oc` subprocess. + +- [ ] Add `go/pkg/template/` — wrap `openshift/library-go/pkg/template/templateprocessing` +- [ ] Port `bonfire/utils.py:RepoFile` — fetch OpenShift Template YAML from GitHub/GitLab raw URLs, + resolve branch → SHA via GitHub/GitLab API, exponential backoff on rate limits +- [ ] Port `bonfire/processor.py:TemplateProcessor` — parameter injection, `--set-parameter`, + `--set-image-tag`, `--single-replicas`, recursive ClowdApp dependency resolution, + untrusted resource limit stripping +- [ ] Port `bonfire/qontract.py` — GraphQL client (`APPS_QUERY`, `ENVS_QUERY`), + 4-layer parameter merge, `sub_refs` +- [ ] Port `bonfire deploy` end-to-end: reserve → fetch → process → apply → wait +- [ ] Deliverable: full `bonfire` CLI with no Python or `oc` runtime dep + +### Move to dedicated repo or replace this repo (after Phase 2) + +- [ ] Create `github.com/redhatinsights/bonfire-go` (or take over `bonfire` name) +- [ ] Move `go/` contents to repo root — standard Go layout (`go.mod` at root, `cmd/`, `pkg/`) +- [ ] Fix import paths (drop the `/go/` segment) +- [ ] Set up CI: `go build`, `go test`, `go vet`, `golangci-lint` +- [ ] Publish binary releases via GitHub Actions (goreleaser) diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 00000000..47e2dc5c --- /dev/null +++ b/go/go.mod @@ -0,0 +1,48 @@ +module github.com/redhatinsights/bonfire + +go 1.24 + +require ( + github.com/google/uuid v1.6.0 + k8s.io/api v0.29.3 + k8s.io/apimachinery v0.29.3 + k8s.io/client-go v0.29.3 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/go-logr/logr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/imdario/mergo v0.3.6 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/oauth2 v0.10.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/term v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.3.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.110.1 // indirect + k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 00000000..be2276eb --- /dev/null +++ b/go/go.sum @@ -0,0 +1,152 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= +github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4= +github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o= +github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= +github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8= +golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA= +golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.29.3 h1:2ORfZ7+bGC3YJqGpV0KSDDEVf8hdGQ6A03/50vj8pmw= +k8s.io/api v0.29.3/go.mod h1:y2yg2NTyHUUkIoTC+phinTnEa3KFM6RZ3szxt014a80= +k8s.io/apimachinery v0.29.3 h1:2tbx+5L7RNvqJjn7RIuIKu9XTsIZ9Z5wX2G22XAa5EU= +k8s.io/apimachinery v0.29.3/go.mod h1:hx/S4V2PNW4OMg3WizRrHutyB5la0iCUbZym+W0EQIU= +k8s.io/client-go v0.29.3 h1:R/zaZbEAxqComZ9FHeQwOh3Y1ZUs7FaHKZdQtIc2WZg= +k8s.io/client-go v0.29.3/go.mod h1:tkDisCvgPfiRpxGnOORfkljmS+UrW+WtXAy2fTvXJB0= +k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0= +k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= +k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780= +k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/go/pkg/ephemeral/client_iface.go b/go/pkg/ephemeral/client_iface.go new file mode 100644 index 00000000..84710547 --- /dev/null +++ b/go/pkg/ephemeral/client_iface.go @@ -0,0 +1,40 @@ +package ephemeral + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// Client is the interface satisfied by EphemeralK8sClient. +// Domain functions (Reserve, Release, etc.) accept Client so they can be +// unit-tested without a live cluster. +type Client interface { + // NamespaceReservation + CreateReservation(ctx context.Context, body map[string]any) (map[string]any, error) + GetReservation(ctx context.Context, name string) (map[string]any, error) + ListReservations(ctx context.Context, labelSelector string) ([]map[string]any, error) + PatchReservation(ctx context.Context, name string, patch map[string]any) (map[string]any, error) + // NamespacePool + ListPools(ctx context.Context) ([]map[string]any, error) + GetPool(ctx context.Context, name string) (map[string]any, error) + // ClusterReservation + CreateClusterReservation(ctx context.Context, body map[string]any) (map[string]any, error) + GetClusterReservation(ctx context.Context, name string) (map[string]any, error) + ListClusterReservations(ctx context.Context, labelSelector string) ([]map[string]any, error) + PatchClusterReservation(ctx context.Context, name string, patch map[string]any) (map[string]any, error) + // ClusterPool + ListClusterPools(ctx context.Context) ([]map[string]any, error) + // Core + GetNamespace(ctx context.Context, name string) (*corev1.Namespace, error) + GetConfigMap(ctx context.Context, name, namespace string) (*corev1.ConfigMap, error) + GetSecret(ctx context.Context, name, namespace string) (*corev1.Secret, error) + ListCRDs(ctx context.Context, gvr schema.GroupVersionResource, namespace string) ([]map[string]any, error) + GetCRD(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string) (map[string]any, error) + // Identity + Whoami(ctx context.Context) string +} + +// compile-time check that EphemeralK8sClient satisfies Client +var _ Client = (*EphemeralK8sClient)(nil) diff --git a/go/pkg/ephemeral/clusters.go b/go/pkg/ephemeral/clusters.go new file mode 100644 index 00000000..75855639 --- /dev/null +++ b/go/pkg/ephemeral/clusters.go @@ -0,0 +1,277 @@ +package ephemeral + +import ( + "context" + "encoding/base64" + "fmt" + "log/slog" + + "github.com/google/uuid" +) + +const ( + defaultClusterDuration = "4h" + defaultClusterPool = "rosa-default" + kubeconfigSecretSuffix = "-kubeconfig" + kubeconfigSecretNamespace = "ephemeral-cluster-operator" +) + +// ClusterReservationResult holds the result of a cluster reservation operation. +type ClusterReservationResult struct { + Name string + Type string + State string + ClusterName string + ConsoleURL string + Expiration string + Requester string + Pool string + Duration string + Created string +} + +// ClusterReserveOptions holds optional parameters for ReserveCluster. +type ClusterReserveOptions struct { + // Name is the reservation name. Auto-generated if empty. + Name string + // Duration is the reservation duration. Defaults to "4h". + Duration string + // Requester is the requester identity. Defaults to client.Whoami(). + Requester string + // Pool is the cluster pool. Defaults to "rosa-default". + Pool string + // Team is an optional team name for cost attribution. + Team string +} + +// ReserveCluster creates a ClusterReservation CR and returns immediately. +// Unlike namespace reservations, cluster provisioning is async (20–40 min). +// The caller must poll GetClusterStatus() until state is "active". +// Mirrors bonfire_lib/clusters.py:reserve_cluster(). +func ReserveCluster(ctx context.Context, client Client, opts ClusterReserveOptions) (*ClusterReservationResult, error) { + if opts.Name == "" { + id := uuid.New().String() + opts.Name = "cluster-reservation-" + id[:8] + } + if opts.Duration == "" { + opts.Duration = defaultClusterDuration + } + if opts.Pool == "" { + opts.Pool = defaultClusterPool + } + if opts.Requester == "" { + opts.Requester = client.Whoami(ctx) + if opts.Requester == "" { + opts.Requester = "bonfire" + } + } + + existing, err := client.GetClusterReservation(ctx, opts.Name) + if err != nil { + return nil, fmt.Errorf("checking for existing cluster reservation: %w", err) + } + if existing != nil { + return nil, NewFatalError("Cluster reservation with name %s already exists", opts.Name) + } + + body := renderClusterReservation(opts.Name, opts.Duration, opts.Requester, opts.Pool, opts.Team) + if _, err = client.CreateClusterReservation(ctx, body); err != nil { + return nil, fmt.Errorf("create cluster reservation: %w", err) + } + + slog.Info("cluster reservation created", + "name", opts.Name, + "requester", opts.Requester, + "duration", opts.Duration, + "pool", opts.Pool, + ) + + return &ClusterReservationResult{ + Name: opts.Name, + State: "waiting", + Requester: opts.Requester, + Pool: opts.Pool, + Type: "cluster", + }, nil +} + +// ReleaseCluster releases a cluster reservation by setting spec.duration to "0s". +// Mirrors bonfire_lib/clusters.py:release_cluster(). +func ReleaseCluster(ctx context.Context, client Client, name string) (*ReleaseResult, error) { + res, err := client.GetClusterReservation(ctx, name) + if err != nil { + return nil, err + } + if res == nil { + return nil, NewFatalError("Cluster reservation %q not found", name) + } + + if _, err = client.PatchClusterReservation(ctx, name, map[string]any{ + "spec": map[string]any{"duration": "0s"}, + }); err != nil { + return nil, fmt.Errorf("patch cluster reservation %q: %w", name, err) + } + + slog.Info("releasing cluster reservation", "name", name) + return &ReleaseResult{Name: name, Released: true}, nil +} + +// ExtendCluster adds the given duration to a cluster reservation's current duration. +// Mirrors bonfire_lib/clusters.py:extend_cluster(). +func ExtendCluster(ctx context.Context, client Client, name, duration string) (*ExtendResult, error) { + res, err := client.GetClusterReservation(ctx, name) + if err != nil { + return nil, err + } + if res == nil { + return nil, NewFatalError("Cluster reservation %q not found", name) + } + + state := nestedString(res, "status", "state") + if state == "expired" { + return nil, NewFatalError("Cluster reservation %q has expired. Reserve a new cluster.", name) + } + + prevSecs, err := HMSToSeconds(nestedString(res, "spec", "duration")) + if err != nil { + return nil, fmt.Errorf("invalid existing duration: %w", err) + } + addSecs, err := HMSToSeconds(duration) + if err != nil { + return nil, fmt.Errorf("invalid extend duration: %w", err) + } + newDuration := DurationFmt(prevSecs + addSecs) + + if _, err = client.PatchClusterReservation(ctx, name, map[string]any{ + "spec": map[string]any{"duration": newDuration}, + }); err != nil { + return nil, fmt.Errorf("patch cluster reservation %q: %w", name, err) + } + + slog.Info("cluster reservation extended", "name", name, "added", duration, "newTotal", newDuration) + return &ExtendResult{Name: name, NewDuration: newDuration}, nil +} + +// GetClusterStatus returns the status of a cluster reservation. +// Returns nil, nil if not found. +// Mirrors bonfire_lib/clusters.py:get_cluster_status(). +func GetClusterStatus(ctx context.Context, client Client, name string) (*ClusterReservationResult, error) { + res, err := client.GetClusterReservation(ctx, name) + if err != nil { + return nil, err + } + if res == nil { + return nil, nil + } + return clusterReservationResult(res), nil +} + +// GetKubeconfig fetches kubeconfig YAML for a provisioned cluster reservation. +// The cluster must be in "active" state. +// Mirrors bonfire_lib/clusters.py:get_kubeconfig(). +func GetKubeconfig(ctx context.Context, client Client, name string) (string, error) { + res, err := client.GetClusterReservation(ctx, name) + if err != nil { + return "", err + } + if res == nil { + return "", NewFatalError("Cluster reservation %q not found", name) + } + + clusterName := nestedString(res, "status", "clusterName") + if clusterName == "" { + state := nestedString(res, "status", "state") + return "", NewFatalError( + "Cluster not yet assigned to reservation %q (state: %s). Poll with GetClusterStatus() until state is 'active'.", + name, state, + ) + } + + secretName := clusterName + kubeconfigSecretSuffix + secret, err := client.GetSecret(ctx, secretName, kubeconfigSecretNamespace) + if err != nil { + return "", err + } + if secret == nil { + return "", NewFatalError( + "Kubeconfig Secret %q not found in namespace %q. The cluster may still be bootstrapping.", + secretName, kubeconfigSecretNamespace, + ) + } + + // Try "kubeconfig" key first, then "value" + raw, ok := secret.Data["kubeconfig"] + if !ok || len(raw) == 0 { + raw = secret.Data["value"] + } + if len(raw) == 0 { + return "", NewFatalError("Kubeconfig Secret %q exists but contains no kubeconfig data.", secretName) + } + + // The API server already base64-decodes secret data, so raw is the actual bytes. + // Attempt a second base64 decode to match the Python implementation's behaviour. + decoded, err2 := base64.StdEncoding.DecodeString(string(raw)) + if err2 != nil { + return string(raw), nil + } + return string(decoded), nil +} + +// ListClusterReservations lists cluster reservations, optionally filtered by requester. +// Returns empty slice if the ClusterReservation CRD is not installed. +// Mirrors bonfire_lib/clusters.py:list_cluster_reservations(). +func ListClusterReservations(ctx context.Context, client Client, requester string) ([]ClusterReservationResult, error) { + var selector string + if requester != "" { + selector = "requester=" + requester + } + raw, err := client.ListClusterReservations(ctx, selector) + if err != nil { + slog.Debug("ClusterReservation CRD not available") + return []ClusterReservationResult{}, nil + } + out := make([]ClusterReservationResult, 0, len(raw)) + for _, res := range raw { + out = append(out, *clusterReservationResult(res)) + } + return out, nil +} + +func clusterReservationResult(res map[string]any) *ClusterReservationResult { + return &ClusterReservationResult{ + Name: nestedString(res, "metadata", "name"), + Type: "cluster", + State: nestedString(res, "status", "state"), + ClusterName: nestedString(res, "status", "clusterName"), + ConsoleURL: nestedString(res, "status", "consoleURL"), + Expiration: nestedString(res, "status", "expiration"), + Requester: nestedString(res, "spec", "requester"), + Pool: nestedString(res, "spec", "pool"), + Duration: nestedString(res, "spec", "duration"), + Created: nestedString(res, "metadata", "creationTimestamp"), + } +} + +// renderClusterReservation builds the ClusterReservation CR body as a map. +// Replaces the Jinja2 clusterreservation.yaml.j2 template. +func renderClusterReservation(name, duration, requester, pool, team string) map[string]any { + spec := map[string]any{ + "duration": duration, + "requester": requester, + "pool": pool, + } + if team != "" { + spec["team"] = team + } + return map[string]any{ + "apiVersion": "cloud.redhat.com/v1alpha1", + "kind": "ClusterReservation", + "metadata": map[string]any{ + "name": name, + "labels": map[string]any{ + "requester": requester, + }, + }, + "spec": spec, + } +} diff --git a/go/pkg/ephemeral/config.go b/go/pkg/ephemeral/config.go new file mode 100644 index 00000000..130e732d --- /dev/null +++ b/go/pkg/ephemeral/config.go @@ -0,0 +1,51 @@ +package ephemeral + +import "os" + +// Settings holds configuration for the ephemeral reservation lifecycle. +// Matches bonfire_lib/config.py Settings dataclass. +type Settings struct { + DefaultNamespacePool string + DefaultReservationDuration string + DefaultRequester string + EphemeralEnvName string + DefaultBaseNamespace string + IsBot bool +} + +// DefaultSettings returns settings with all defaults applied. +func DefaultSettings() Settings { + return Settings{ + DefaultNamespacePool: "default", + DefaultReservationDuration: "1h", + DefaultRequester: "", + EphemeralEnvName: "insights-ephemeral", + DefaultBaseNamespace: "ephemeral-base", + IsBot: false, + } +} + +// SettingsFromEnv loads settings from environment variables. +// Matches bonfire_lib/config.py Settings.from_env(). +func SettingsFromEnv() Settings { + s := DefaultSettings() + if v := os.Getenv("BONFIRE_DEFAULT_NAMESPACE_POOL"); v != "" { + s.DefaultNamespacePool = v + } + if v := os.Getenv("BONFIRE_DEFAULT_DURATION"); v != "" { + s.DefaultReservationDuration = v + } + if v := os.Getenv("BONFIRE_NS_REQUESTER"); v != "" { + s.DefaultRequester = v + } + if v := os.Getenv("EPHEMERAL_ENV_NAME"); v != "" { + s.EphemeralEnvName = v + } + if v := os.Getenv("DEFAULT_BASE_NAMESPACE"); v != "" { + s.DefaultBaseNamespace = v + } + if v := os.Getenv("BONFIRE_BOT"); v == "true" { + s.IsBot = true + } + return s +} diff --git a/go/pkg/ephemeral/json.go b/go/pkg/ephemeral/json.go new file mode 100644 index 00000000..f831b74d --- /dev/null +++ b/go/pkg/ephemeral/json.go @@ -0,0 +1,74 @@ +package ephemeral + +import "encoding/json" + +// marshalJSON encodes v to JSON bytes. Used internally for patch bodies. +func marshalJSON(v any) ([]byte, error) { + return json.Marshal(v) +} + +// nestedString is a helper to safely extract a nested string from an +// unstructured map without panicking on nil or missing keys. +// path is a sequence of map keys to traverse. +func nestedString(obj map[string]any, path ...string) string { + cur := obj + for i, key := range path { + val, ok := cur[key] + if !ok { + return "" + } + if i == len(path)-1 { + s, _ := val.(string) + return s + } + cur, ok = val.(map[string]any) + if !ok { + return "" + } + } + return "" +} + +// nestedInt64 safely extracts a nested int64 (or numeric) value. +func nestedInt64(obj map[string]any, path ...string) int64 { + cur := obj + for i, key := range path { + val, ok := cur[key] + if !ok { + return 0 + } + if i == len(path)-1 { + switch v := val.(type) { + case int64: + return v + case int: + return int64(v) + case float64: + return int64(v) + } + return 0 + } + cur, ok = val.(map[string]any) + if !ok { + return 0 + } + } + return 0 +} + +// nestedMap safely extracts a nested map[string]any. +func nestedMap(obj map[string]any, path ...string) map[string]any { + cur := obj + for _, key := range path { + val, ok := cur[key] + if !ok { + return nil + } + m, ok := val.(map[string]any) + if !ok { + return nil + } + cur = m + } + return cur +} diff --git a/go/pkg/ephemeral/k8s_client.go b/go/pkg/ephemeral/k8s_client.go new file mode 100644 index 00000000..5687bf17 --- /dev/null +++ b/go/pkg/ephemeral/k8s_client.go @@ -0,0 +1,448 @@ +package ephemeral + +import ( + "context" + "encoding/base64" + "fmt" + "log/slog" + "os" + + authv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +const ( + crdGroup = "cloud.redhat.com" + crdVersion = "v1alpha1" + + kindNamespaceReservation = "NamespaceReservation" + kindNamespacePool = "NamespacePool" + kindClusterReservation = "ClusterReservation" + kindClusterPool = "ClusterPool" + kindClowdApp = "ClowdApp" + kindFrontend = "Frontend" + kindFrontendEnvironment = "FrontendEnvironment" + + defaultReadTimeout = 30 + defaultWriteTimeout = 60 +) + +var ( + gvrNamespaceReservation = schema.GroupVersionResource{Group: crdGroup, Version: crdVersion, Resource: "namespacereservations"} + gvrNamespacePool = schema.GroupVersionResource{Group: crdGroup, Version: crdVersion, Resource: "namespacepools"} + gvrClusterReservation = schema.GroupVersionResource{Group: crdGroup, Version: crdVersion, Resource: "clusterreservations"} + gvrClusterPool = schema.GroupVersionResource{Group: crdGroup, Version: crdVersion, Resource: "clusterpools"} + gvrClowdApp = schema.GroupVersionResource{Group: crdGroup, Version: crdVersion, Resource: "clowdapps"} + gvrFrontend = schema.GroupVersionResource{Group: crdGroup, Version: crdVersion, Resource: "frontends"} + gvrFrontendEnvironment = schema.GroupVersionResource{Group: crdGroup, Version: crdVersion, Resource: "frontendenvironments"} +) + +// AuthMode describes how the client authenticated to the cluster. +type AuthMode string + +const ( + AuthModeToken AuthMode = "token" + AuthModeInCluster AuthMode = "in-cluster" + AuthModeKubeconfig AuthMode = "kubeconfig" +) + +// ClientOptions holds optional parameters for NewClient. +type ClientOptions struct { + // KubeconfigPath overrides the default kubeconfig file location. + KubeconfigPath string + // Context selects a specific kubeconfig context. + Context string + // Server is the API server URL for explicit token auth. + Server string + // Token is the bearer token for explicit token auth. + Token string + // CAData is base64-encoded CA certificate data for token auth. + CAData string + // SkipTLS disables TLS verification (token auth only). + SkipTLS bool +} + +// EphemeralK8sClient is the Kubernetes API client for ephemeral resource CRDs. +// It mirrors bonfire_lib/k8s_client.py:EphemeralK8sClient. +// +// Auth modes (auto-detected in priority order): +// 1. Explicit Server + Token (ClientOptions.Server + ClientOptions.Token) +// 2. In-cluster service account (/var/run/secrets/kubernetes.io/serviceaccount/token) +// 3. Kubeconfig file (KUBECONFIG env var or ~/.kube/config) +type EphemeralK8sClient struct { + authMode AuthMode + dynamic dynamic.Interface + core kubernetes.Interface + restConfig *rest.Config +} + +// NewClient creates an EphemeralK8sClient using the provided options. +// Auth mode is selected in the priority order documented on EphemeralK8sClient. +func NewClient(opts ClientOptions) (*EphemeralK8sClient, error) { + var cfg *rest.Config + var authMode AuthMode + var err error + + if opts.Server != "" && opts.Token != "" { + // Priority 1: explicit server + token + authMode = AuthModeToken + cfg = &rest.Config{ + Host: opts.Server, + BearerToken: opts.Token, + } + if opts.SkipTLS { + cfg.TLSClientConfig = rest.TLSClientConfig{Insecure: true} + } else if opts.CAData != "" { + caBytes, err2 := base64.StdEncoding.DecodeString(opts.CAData) + if err2 != nil { + return nil, fmt.Errorf("invalid CA data: %w", err2) + } + cfg.TLSClientConfig = rest.TLSClientConfig{CAData: caBytes} + } + } else { + // Try kubeconfig first (Priority 3), fall back to in-cluster (Priority 2). + // This mirrors the Python behaviour: prefer explicit oc-login credentials even + // when running inside a pod (e.g. GitLab CI runners). + loadRules := clientcmd.NewDefaultClientConfigLoadingRules() + if opts.KubeconfigPath != "" { + loadRules.ExplicitPath = opts.KubeconfigPath + } + overrides := &clientcmd.ConfigOverrides{} + if opts.Context != "" { + overrides.CurrentContext = opts.Context + } + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadRules, overrides) + cfg, err = kubeConfig.ClientConfig() + if err != nil { + // Kubeconfig failed — try in-cluster + if isInCluster() { + slog.Warn("kubeconfig auth failed, attempting in-cluster fallback", "err", err) + cfg, err = rest.InClusterConfig() + if err != nil { + return nil, fmt.Errorf("both kubeconfig and in-cluster auth failed: %w", err) + } + authMode = AuthModeInCluster + } else { + return nil, fmt.Errorf("unable to load kubeconfig and not running in-cluster: %w", err) + } + } else { + authMode = AuthModeKubeconfig + // Verify the kubeconfig actually works by hitting /api + testClient, err2 := kubernetes.NewForConfig(cfg) + if err2 != nil { + return nil, fmt.Errorf("failed to build k8s client: %w", err2) + } + if _, err2 = testClient.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{Limit: 1}); err2 != nil { + if isInCluster() { + slog.Warn("kubeconfig connectivity check failed, attempting in-cluster fallback", "err", err2) + cfg, err = rest.InClusterConfig() + if err != nil { + return nil, fmt.Errorf("both kubeconfig and in-cluster auth failed: %w", err) + } + authMode = AuthModeInCluster + } else { + return nil, fmt.Errorf("kubeconfig auth failed: %w", err2) + } + } + } + } + + dynClient, err := dynamic.NewForConfig(cfg) + if err != nil { + return nil, fmt.Errorf("failed to create dynamic client: %w", err) + } + coreClient, err := kubernetes.NewForConfig(cfg) + if err != nil { + return nil, fmt.Errorf("failed to create core client: %w", err) + } + + return &EphemeralK8sClient{ + authMode: authMode, + dynamic: dynClient, + core: coreClient, + restConfig: cfg, + }, nil +} + +// isInCluster returns true if running inside a Kubernetes pod. +func isInCluster() bool { + _, err := os.Stat("/var/run/secrets/kubernetes.io/serviceaccount/token") + return err == nil +} + +// AuthMode returns the auth mode used by this client. +func (c *EphemeralK8sClient) AuthMode() AuthMode { return c.authMode } + +// --- helpers --- + +func ignoreNotFound(err error) error { + if errors.IsNotFound(err) { + return nil + } + return err +} + +func (c *EphemeralK8sClient) gvrResource(gvr schema.GroupVersionResource) dynamic.ResourceInterface { + return c.dynamic.Resource(gvr) +} + +func (c *EphemeralK8sClient) gvrNamespacedResource(gvr schema.GroupVersionResource, namespace string) dynamic.ResourceInterface { + return c.dynamic.Resource(gvr).Namespace(namespace) +} + +// --- NamespaceReservation operations --- + +// CreateReservation creates a NamespaceReservation CR. +func (c *EphemeralK8sClient) CreateReservation(ctx context.Context, body map[string]any) (map[string]any, error) { + obj := &unstructured.Unstructured{Object: body} + result, err := c.gvrResource(gvrNamespaceReservation).Create(ctx, obj, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("create NamespaceReservation: %w", err) + } + return result.Object, nil +} + +// GetReservation returns a NamespaceReservation by name. Returns nil, nil if not found. +func (c *EphemeralK8sClient) GetReservation(ctx context.Context, name string) (map[string]any, error) { + result, err := c.gvrResource(gvrNamespaceReservation).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, ignoreNotFound(err) + } + return result.Object, nil +} + +// ListReservations lists all NamespaceReservation CRs, optionally filtered by labelSelector. +func (c *EphemeralK8sClient) ListReservations(ctx context.Context, labelSelector string) ([]map[string]any, error) { + opts := metav1.ListOptions{LabelSelector: labelSelector} + list, err := c.gvrResource(gvrNamespaceReservation).List(ctx, opts) + if err != nil { + return nil, fmt.Errorf("list NamespaceReservations: %w", err) + } + return unstructuredItems(list), nil +} + +// PatchReservation applies a merge patch to a NamespaceReservation. +func (c *EphemeralK8sClient) PatchReservation(ctx context.Context, name string, patch map[string]any) (map[string]any, error) { + data, err := marshalJSON(patch) + if err != nil { + return nil, err + } + result, err := c.gvrResource(gvrNamespaceReservation).Patch(ctx, name, types.MergePatchType, data, metav1.PatchOptions{}) + if err != nil { + return nil, fmt.Errorf("patch NamespaceReservation %q: %w", name, err) + } + return result.Object, nil +} + +// --- NamespacePool operations --- + +// ListPools lists all NamespacePool CRs. +func (c *EphemeralK8sClient) ListPools(ctx context.Context) ([]map[string]any, error) { + list, err := c.gvrResource(gvrNamespacePool).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("list NamespacePools: %w", err) + } + return unstructuredItems(list), nil +} + +// GetPool returns a NamespacePool by name. Returns nil, nil if not found. +func (c *EphemeralK8sClient) GetPool(ctx context.Context, name string) (map[string]any, error) { + result, err := c.gvrResource(gvrNamespacePool).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, ignoreNotFound(err) + } + return result.Object, nil +} + +// --- ClusterReservation operations --- + +// CreateClusterReservation creates a ClusterReservation CR. +func (c *EphemeralK8sClient) CreateClusterReservation(ctx context.Context, body map[string]any) (map[string]any, error) { + obj := &unstructured.Unstructured{Object: body} + result, err := c.gvrResource(gvrClusterReservation).Create(ctx, obj, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("create ClusterReservation: %w", err) + } + return result.Object, nil +} + +// GetClusterReservation returns a ClusterReservation by name. Returns nil, nil if not found. +func (c *EphemeralK8sClient) GetClusterReservation(ctx context.Context, name string) (map[string]any, error) { + result, err := c.gvrResource(gvrClusterReservation).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, ignoreNotFound(err) + } + return result.Object, nil +} + +// ListClusterReservations lists all ClusterReservation CRs, optionally filtered by labelSelector. +func (c *EphemeralK8sClient) ListClusterReservations(ctx context.Context, labelSelector string) ([]map[string]any, error) { + opts := metav1.ListOptions{LabelSelector: labelSelector} + list, err := c.gvrResource(gvrClusterReservation).List(ctx, opts) + if err != nil { + return nil, fmt.Errorf("list ClusterReservations: %w", err) + } + return unstructuredItems(list), nil +} + +// PatchClusterReservation applies a merge patch to a ClusterReservation. +func (c *EphemeralK8sClient) PatchClusterReservation(ctx context.Context, name string, patch map[string]any) (map[string]any, error) { + data, err := marshalJSON(patch) + if err != nil { + return nil, err + } + result, err := c.gvrResource(gvrClusterReservation).Patch(ctx, name, types.MergePatchType, data, metav1.PatchOptions{}) + if err != nil { + return nil, fmt.Errorf("patch ClusterReservation %q: %w", name, err) + } + return result.Object, nil +} + +// --- ClusterPool operations --- + +// ListClusterPools lists all ClusterPool CRs. +func (c *EphemeralK8sClient) ListClusterPools(ctx context.Context) ([]map[string]any, error) { + list, err := c.gvrResource(gvrClusterPool).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("list ClusterPools: %w", err) + } + return unstructuredItems(list), nil +} + +// GetClusterPool returns a ClusterPool by name. Returns nil, nil if not found. +func (c *EphemeralK8sClient) GetClusterPool(ctx context.Context, name string) (map[string]any, error) { + result, err := c.gvrResource(gvrClusterPool).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, ignoreNotFound(err) + } + return result.Object, nil +} + +// --- Namespace operations (CoreV1Api) --- + +// GetNamespace returns a namespace by name. Returns nil, nil if not found. +func (c *EphemeralK8sClient) GetNamespace(ctx context.Context, name string) (*corev1.Namespace, error) { + ns, err := c.core.CoreV1().Namespaces().Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("get namespace %q: %w", name, err) + } + return ns, nil +} + +// GetConfigMap returns a ConfigMap by name and namespace. Returns nil, nil if not found. +func (c *EphemeralK8sClient) GetConfigMap(ctx context.Context, name, namespace string) (*corev1.ConfigMap, error) { + cm, err := c.core.CoreV1().ConfigMaps(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("get configmap %q/%q: %w", namespace, name, err) + } + return cm, nil +} + +// GetSecret returns a Secret by name and namespace. Returns nil, nil if not found. +func (c *EphemeralK8sClient) GetSecret(ctx context.Context, name, namespace string) (*corev1.Secret, error) { + secret, err := c.core.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("get secret %q/%q: %w", namespace, name, err) + } + return secret, nil +} + +// --- Generic CRD operations --- + +// ListCRDs lists CRDs of a given GVR, optionally scoped to a namespace. +func (c *EphemeralK8sClient) ListCRDs(ctx context.Context, gvr schema.GroupVersionResource, namespace string) ([]map[string]any, error) { + var ri dynamic.ResourceInterface + if namespace != "" { + ri = c.dynamic.Resource(gvr).Namespace(namespace) + } else { + ri = c.dynamic.Resource(gvr) + } + list, err := ri.List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("list %s: %w", gvr.Resource, err) + } + return unstructuredItems(list), nil +} + +// GetCRD returns a single CRD by GVR and name, optionally in a namespace. Returns nil, nil if not found. +func (c *EphemeralK8sClient) GetCRD(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string) (map[string]any, error) { + var ri dynamic.ResourceInterface + if namespace != "" { + ri = c.dynamic.Resource(gvr).Namespace(namespace) + } else { + ri = c.dynamic.Resource(gvr) + } + result, err := ri.Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, ignoreNotFound(err) + } + return result.Object, nil +} + +// --- Identity --- + +// Whoami returns the current authenticated user identity, sanitized for use +// as a K8s label value (@ → _at_, : → _). +// Falls back to "unknown" if identity cannot be determined. +func (c *EphemeralK8sClient) Whoami(ctx context.Context) string { + if c.authMode == AuthModeKubeconfig { + loadRules := clientcmd.NewDefaultClientConfigLoadingRules() + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadRules, &clientcmd.ConfigOverrides{}) + rawConfig, err := kubeConfig.RawConfig() + if err == nil { + if contextName := rawConfig.CurrentContext; contextName != "" { + if ctx2, ok := rawConfig.Contexts[contextName]; ok && ctx2 != nil { + user := ctx2.AuthInfo + if user != "" { + return sanitizeUsername(extractUsername(user)) + } + } + } + } + } + + // Token review for token/in-cluster modes + token := c.restConfig.BearerToken + if token == "" { + return "unknown" + } + review, err := c.core.AuthenticationV1().TokenReviews().Create(ctx, &authv1.TokenReview{ + Spec: authv1.TokenReviewSpec{Token: token}, + }, metav1.CreateOptions{}) + if err != nil { + slog.Debug("whoami token review failed", "err", err) + return "unknown" + } + if review.Status.User.Username != "" { + return sanitizeUsername(review.Status.User.Username) + } + return "unknown" +} + +// --- internal helpers --- + +func unstructuredItems(list *unstructured.UnstructuredList) []map[string]any { + out := make([]map[string]any, len(list.Items)) + for i, item := range list.Items { + out[i] = item.Object + } + return out +} diff --git a/go/pkg/ephemeral/mock_client_test.go b/go/pkg/ephemeral/mock_client_test.go new file mode 100644 index 00000000..32a30906 --- /dev/null +++ b/go/pkg/ephemeral/mock_client_test.go @@ -0,0 +1,161 @@ +package ephemeral + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// compile-time check: testClient satisfies the exported Client interface +var _ Client = (*testClient)(nil) + +// testClient is a test double for Client. +type testClient struct { + createReservationFn func(ctx context.Context, body map[string]any) (map[string]any, error) + getReservationFn func(ctx context.Context, name string) (map[string]any, error) + listReservationsFn func(ctx context.Context, labelSelector string) ([]map[string]any, error) + patchReservationFn func(ctx context.Context, name string, patch map[string]any) (map[string]any, error) + listPoolsFn func(ctx context.Context) ([]map[string]any, error) + getPoolFn func(ctx context.Context, name string) (map[string]any, error) + createClusterReservationFn func(ctx context.Context, body map[string]any) (map[string]any, error) + getClusterReservationFn func(ctx context.Context, name string) (map[string]any, error) + listClusterReservationsFn func(ctx context.Context, labelSelector string) ([]map[string]any, error) + patchClusterReservationFn func(ctx context.Context, name string, patch map[string]any) (map[string]any, error) + listClusterPoolsFn func(ctx context.Context) ([]map[string]any, error) + getNamespaceFn func(ctx context.Context, name string) (*corev1.Namespace, error) + getConfigMapFn func(ctx context.Context, name, namespace string) (*corev1.ConfigMap, error) + getSecretFn func(ctx context.Context, name, namespace string) (*corev1.Secret, error) + listCRDsFn func(ctx context.Context, gvr schema.GroupVersionResource, namespace string) ([]map[string]any, error) + getCRDFn func(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string) (map[string]any, error) + whoamiFn func(ctx context.Context) string +} + +func (m *testClient) CreateReservation(ctx context.Context, body map[string]any) (map[string]any, error) { + return m.createReservationFn(ctx, body) +} +func (m *testClient) GetReservation(ctx context.Context, name string) (map[string]any, error) { + return m.getReservationFn(ctx, name) +} +func (m *testClient) ListReservations(ctx context.Context, sel string) ([]map[string]any, error) { + return m.listReservationsFn(ctx, sel) +} +func (m *testClient) PatchReservation(ctx context.Context, name string, patch map[string]any) (map[string]any, error) { + return m.patchReservationFn(ctx, name, patch) +} +func (m *testClient) ListPools(ctx context.Context) ([]map[string]any, error) { + return m.listPoolsFn(ctx) +} +func (m *testClient) GetPool(ctx context.Context, name string) (map[string]any, error) { + return m.getPoolFn(ctx, name) +} +func (m *testClient) CreateClusterReservation(ctx context.Context, body map[string]any) (map[string]any, error) { + return m.createClusterReservationFn(ctx, body) +} +func (m *testClient) GetClusterReservation(ctx context.Context, name string) (map[string]any, error) { + return m.getClusterReservationFn(ctx, name) +} +func (m *testClient) ListClusterReservations(ctx context.Context, sel string) ([]map[string]any, error) { + return m.listClusterReservationsFn(ctx, sel) +} +func (m *testClient) PatchClusterReservation(ctx context.Context, name string, patch map[string]any) (map[string]any, error) { + return m.patchClusterReservationFn(ctx, name, patch) +} +func (m *testClient) ListClusterPools(ctx context.Context) ([]map[string]any, error) { + return m.listClusterPoolsFn(ctx) +} +func (m *testClient) GetNamespace(ctx context.Context, name string) (*corev1.Namespace, error) { + return m.getNamespaceFn(ctx, name) +} +func (m *testClient) GetConfigMap(ctx context.Context, name, ns string) (*corev1.ConfigMap, error) { + return m.getConfigMapFn(ctx, name, ns) +} +func (m *testClient) GetSecret(ctx context.Context, name, ns string) (*corev1.Secret, error) { + return m.getSecretFn(ctx, name, ns) +} +func (m *testClient) ListCRDs(ctx context.Context, gvr schema.GroupVersionResource, ns string) ([]map[string]any, error) { + return m.listCRDsFn(ctx, gvr, ns) +} +func (m *testClient) GetCRD(ctx context.Context, gvr schema.GroupVersionResource, name, ns string) (map[string]any, error) { + return m.getCRDFn(ctx, gvr, name, ns) +} +func (m *testClient) Whoami(ctx context.Context) string { + if m.whoamiFn != nil { + return m.whoamiFn(ctx) + } + return "test_at_user.com" +} + +// sampleReservation returns a realistic NamespaceReservation CR dict. +func sampleReservation() map[string]any { + return map[string]any{ + "apiVersion": "cloud.redhat.com/v1alpha1", + "kind": "NamespaceReservation", + "metadata": map[string]any{ + "name": "test-reservation", + "labels": map[string]any{"requester": "test-user"}, + }, + "spec": map[string]any{ + "duration": "1h", + "requester": "test-user", + "pool": "default", + }, + "status": map[string]any{ + "state": "active", + "namespace": "ephemeral-abc123", + "expiration": "2026-04-09T12:00:00Z", + "pool": "default", + }, + } +} + +// samplePool returns a realistic NamespacePool CR dict. +func samplePool() map[string]any { + return map[string]any{ + "apiVersion": "cloud.redhat.com/v1alpha1", + "kind": "NamespacePool", + "metadata": map[string]any{"name": "default"}, + "spec": map[string]any{ + "size": float64(5), + "sizeLimit": float64(10), + "description": "Default pool", + }, + "status": map[string]any{ + "ready": float64(3), + "creating": float64(1), + "reserved": float64(2), + }, + } +} + +// mustNotErr fails the test if err is non-nil. +func mustNotErr(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// noopFns returns a testClient with all functions stubbed to return zero values. +// Override individual fields in each test. +func noopClient() *testClient { + return &testClient{ + createReservationFn: func(_ context.Context, _ map[string]any) (map[string]any, error) { return nil, nil }, + getReservationFn: func(_ context.Context, _ string) (map[string]any, error) { return nil, nil }, + listReservationsFn: func(_ context.Context, _ string) ([]map[string]any, error) { return nil, nil }, + patchReservationFn: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { return nil, nil }, + listPoolsFn: func(_ context.Context) ([]map[string]any, error) { return nil, nil }, + getPoolFn: func(_ context.Context, _ string) (map[string]any, error) { return nil, nil }, + createClusterReservationFn: func(_ context.Context, _ map[string]any) (map[string]any, error) { return nil, nil }, + getClusterReservationFn: func(_ context.Context, _ string) (map[string]any, error) { return nil, nil }, + listClusterReservationsFn: func(_ context.Context, _ string) ([]map[string]any, error) { return nil, nil }, + patchClusterReservationFn: func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { return nil, nil }, + listClusterPoolsFn: func(_ context.Context) ([]map[string]any, error) { return nil, nil }, + getNamespaceFn: func(_ context.Context, _ string) (*corev1.Namespace, error) { return nil, nil }, + getConfigMapFn: func(_ context.Context, _, _ string) (*corev1.ConfigMap, error) { return nil, nil }, + getSecretFn: func(_ context.Context, _, _ string) (*corev1.Secret, error) { return nil, nil }, + listCRDsFn: func(_ context.Context, _ schema.GroupVersionResource, _ string) ([]map[string]any, error) { return nil, nil }, + getCRDFn: func(_ context.Context, _ schema.GroupVersionResource, _, _ string) (map[string]any, error) { return nil, nil }, + } +} diff --git a/go/pkg/ephemeral/pools.go b/go/pkg/ephemeral/pools.go new file mode 100644 index 00000000..a19941df --- /dev/null +++ b/go/pkg/ephemeral/pools.go @@ -0,0 +1,149 @@ +package ephemeral + +import ( + "context" + "log/slog" +) + +// PoolInfo holds capacity information for a namespace pool. +type PoolInfo struct { + Name string + Description string + Size int64 + SizeLimit *int64 // nil means unlimited + Ready int64 + Creating int64 + Reserved int64 +} + +// ClusterPoolInfo holds capacity information for a cluster pool. +type ClusterPoolInfo struct { + Name string + Description string + Size int64 + SizeLimit int64 + Ready int64 + Provisioning int64 + Reserved int64 +} + +// ListPools lists all namespace pools with capacity stats. +// Mirrors bonfire_lib/pools.py:list_pools(). +func ListPools(ctx context.Context, client Client) ([]PoolInfo, error) { + pools, err := client.ListPools(ctx) + if err != nil { + return nil, err + } + out := make([]PoolInfo, 0, len(pools)) + for _, pool := range pools { + spec := nestedMap(pool, "spec") + status := nestedMap(pool, "status") + if spec == nil { + spec = map[string]any{} + } + if status == nil { + status = map[string]any{} + } + + info := PoolInfo{ + Name: nestedString(pool, "metadata", "name"), + Description: nestedString(spec, "description"), + Size: nestedInt64(spec, "size"), + Ready: nestedInt64(status, "ready"), + Creating: nestedInt64(status, "creating"), + Reserved: nestedInt64(status, "reserved"), + } + if sl, ok := spec["sizeLimit"]; ok && sl != nil { + v := nestedInt64(spec, "sizeLimit") + info.SizeLimit = &v + } + out = append(out, info) + } + return out, nil +} + +// GetPoolCapacity returns capacity details for a specific pool. +// Returns nil, nil if pool not found. +// Mirrors bonfire_lib/pools.py:get_pool_capacity(). +func GetPoolCapacity(ctx context.Context, client Client, poolName string) (*PoolInfo, error) { + pool, err := client.GetPool(ctx, poolName) + if err != nil { + return nil, err + } + if pool == nil { + return nil, nil + } + spec := nestedMap(pool, "spec") + status := nestedMap(pool, "status") + if spec == nil { + spec = map[string]any{} + } + if status == nil { + status = map[string]any{} + } + info := &PoolInfo{ + Name: poolName, + Description: nestedString(spec, "description"), + Size: nestedInt64(spec, "size"), + Ready: nestedInt64(status, "ready"), + Creating: nestedInt64(status, "creating"), + Reserved: nestedInt64(status, "reserved"), + } + if sl, ok := spec["sizeLimit"]; ok && sl != nil { + v := nestedInt64(spec, "sizeLimit") + info.SizeLimit = &v + } + return info, nil +} + +// ListClusterPools lists all cluster pools with capacity stats. +// Returns an empty slice if the ClusterPool CRD is not installed. +// Mirrors bonfire_lib/pools.py:list_cluster_pools(). +func ListClusterPools(ctx context.Context, client Client) ([]ClusterPoolInfo, error) { + pools, err := client.ListClusterPools(ctx) + if err != nil { + slog.Debug("ClusterPool CRD not available, skipping cluster pools", "err", err) + return []ClusterPoolInfo{}, nil + } + out := make([]ClusterPoolInfo, 0, len(pools)) + for _, pool := range pools { + spec := nestedMap(pool, "spec") + status := nestedMap(pool, "status") + if spec == nil { + spec = map[string]any{} + } + if status == nil { + status = map[string]any{} + } + out = append(out, ClusterPoolInfo{ + Name: nestedString(pool, "metadata", "name"), + Description: nestedString(spec, "description"), + Size: nestedInt64(spec, "size"), + SizeLimit: nestedInt64(spec, "sizeLimit"), + Ready: nestedInt64(status, "ready"), + Provisioning: nestedInt64(status, "provisioning"), + Reserved: nestedInt64(status, "reserved"), + }) + } + return out, nil +} + +// AllPools holds both namespace and cluster pool lists. +type AllPools struct { + NamespacePools []PoolInfo + ClusterPools []ClusterPoolInfo +} + +// ListAllPools lists both namespace and cluster pools. +// Mirrors bonfire_lib/pools.py:list_all_pools(). +func ListAllPools(ctx context.Context, client Client) (*AllPools, error) { + ns, err := ListPools(ctx, client) + if err != nil { + return nil, err + } + cl, err := ListClusterPools(ctx, client) + if err != nil { + return nil, err + } + return &AllPools{NamespacePools: ns, ClusterPools: cl}, nil +} diff --git a/go/pkg/ephemeral/pools_test.go b/go/pkg/ephemeral/pools_test.go new file mode 100644 index 00000000..38fd4cf5 --- /dev/null +++ b/go/pkg/ephemeral/pools_test.go @@ -0,0 +1,142 @@ +package ephemeral + +import ( + "context" + "fmt" + "testing" +) + +func TestListPools_StructuredOutput(t *testing.T) { + mc := noopClient() + mc.listPoolsFn = func(_ context.Context) ([]map[string]any, error) { + return []map[string]any{samplePool()}, nil + } + + result, err := ListPools(context.Background(), mc) + mustNotErr(t, err) + if len(result) != 1 { + t.Fatalf("len = %d, want 1", len(result)) + } + p := result[0] + if p.Name != "default" { + t.Errorf("Name = %q, want %q", p.Name, "default") + } + if p.Description != "Default pool" { + t.Errorf("Description = %q, want %q", p.Description, "Default pool") + } + if p.Size != 5 { + t.Errorf("Size = %d, want 5", p.Size) + } + if p.SizeLimit == nil || *p.SizeLimit != 10 { + t.Errorf("SizeLimit = %v, want 10", p.SizeLimit) + } + if p.Ready != 3 { + t.Errorf("Ready = %d, want 3", p.Ready) + } + if p.Creating != 1 { + t.Errorf("Creating = %d, want 1", p.Creating) + } + if p.Reserved != 2 { + t.Errorf("Reserved = %d, want 2", p.Reserved) + } +} + +func TestListPools_Empty(t *testing.T) { + mc := noopClient() + mc.listPoolsFn = func(_ context.Context) ([]map[string]any, error) { + return []map[string]any{}, nil + } + result, err := ListPools(context.Background(), mc) + mustNotErr(t, err) + if len(result) != 0 { + t.Errorf("len = %d, want 0", len(result)) + } +} + +func TestListPools_MissingOptionalFields(t *testing.T) { + mc := noopClient() + mc.listPoolsFn = func(_ context.Context) ([]map[string]any, error) { + return []map[string]any{{ + "metadata": map[string]any{"name": "sparse"}, + "spec": map[string]any{}, + "status": map[string]any{}, + }}, nil + } + result, err := ListPools(context.Background(), mc) + mustNotErr(t, err) + p := result[0] + if p.Description != "" { + t.Errorf("Description = %q, want empty", p.Description) + } + if p.Size != 0 { + t.Errorf("Size = %d, want 0", p.Size) + } + if p.SizeLimit != nil { + t.Errorf("SizeLimit = %v, want nil", p.SizeLimit) + } + if p.Ready != 0 || p.Creating != 0 || p.Reserved != 0 { + t.Error("expected all status counters to be 0") + } +} + +func TestListPools_Multiple(t *testing.T) { + pool2 := map[string]any{ + "metadata": map[string]any{"name": "minimal"}, + "spec": map[string]any{"size": float64(2), "description": "Minimal pool"}, + "status": map[string]any{"ready": float64(1), "creating": float64(0), "reserved": float64(1)}, + } + mc := noopClient() + mc.listPoolsFn = func(_ context.Context) ([]map[string]any, error) { + return []map[string]any{samplePool(), pool2}, nil + } + result, err := ListPools(context.Background(), mc) + mustNotErr(t, err) + if len(result) != 2 { + t.Fatalf("len = %d, want 2", len(result)) + } + if result[0].Name != "default" || result[1].Name != "minimal" { + t.Errorf("names = %q, %q; want 'default', 'minimal'", result[0].Name, result[1].Name) + } +} + +func TestGetPoolCapacity_Existing(t *testing.T) { + mc := noopClient() + mc.getPoolFn = func(_ context.Context, _ string) (map[string]any, error) { + return samplePool(), nil + } + result, err := GetPoolCapacity(context.Background(), mc, "default") + mustNotErr(t, err) + if result == nil { + t.Fatal("expected non-nil result") + } + if result.Name != "default" { + t.Errorf("Name = %q, want %q", result.Name, "default") + } + if result.Size != 5 { + t.Errorf("Size = %d, want 5", result.Size) + } +} + +func TestGetPoolCapacity_Nonexistent(t *testing.T) { + mc := noopClient() + mc.getPoolFn = func(_ context.Context, _ string) (map[string]any, error) { + return nil, nil + } + result, err := GetPoolCapacity(context.Background(), mc, "nonexistent") + mustNotErr(t, err) + if result != nil { + t.Errorf("expected nil result, got %v", result) + } +} + +func TestListClusterPools_CRDNotAvailable(t *testing.T) { + mc := noopClient() + mc.listClusterPoolsFn = func(_ context.Context) ([]map[string]any, error) { + return nil, fmt.Errorf("CRD not found") + } + result, err := ListClusterPools(context.Background(), mc) + mustNotErr(t, err) // should swallow the error + if len(result) != 0 { + t.Errorf("len = %d, want 0", len(result)) + } +} diff --git a/go/pkg/ephemeral/reservations.go b/go/pkg/ephemeral/reservations.go new file mode 100644 index 00000000..49d2c54b --- /dev/null +++ b/go/pkg/ephemeral/reservations.go @@ -0,0 +1,247 @@ +package ephemeral + +import ( + "context" + "fmt" + "log/slog" + + "github.com/google/uuid" +) + +const defaultReservationTimeout = 600 // 10 minutes + +// ReservationResult holds the result of a successful namespace reservation. +type ReservationResult struct { + Name string + Namespace string + State string + Expiration string + Requester string + Pool string +} + +// ReserveOptions holds optional parameters for Reserve. +type ReserveOptions struct { + // Name is the reservation name. Auto-generated if empty. + Name string + // Duration is the reservation duration (e.g. "1h"). Defaults to "1h". + Duration string + // Requester is the requester identity. Defaults to client.Whoami(). + Requester string + // Pool is the namespace pool to reserve from. Defaults to "default". + Pool string + // Team is an optional team name for cost attribution. + Team string + // SecretsSourceNamespace overrides the secret source namespace. + SecretsSourceNamespace string + // Timeout is max seconds to wait for namespace assignment. Defaults to 600. + Timeout int +} + +// Reserve creates a NamespaceReservation CR and polls until a namespace is assigned. +// Mirrors bonfire_lib/reservations.py:reserve(). +// +// Returns ReservationResult on success. +// Returns *FatalError if reservation already exists. +// Returns context.DeadlineExceeded or a timeout error if namespace not assigned within Timeout. +func Reserve(ctx context.Context, client Client, opts ReserveOptions) (*ReservationResult, error) { + if opts.Name == "" { + id := uuid.New().String() + opts.Name = "bonfire-reservation-" + id[:8] + } + if opts.Duration == "" { + opts.Duration = "1h" + } + if opts.Pool == "" { + opts.Pool = "default" + } + if opts.Timeout == 0 { + opts.Timeout = defaultReservationTimeout + } + if opts.Requester == "" { + opts.Requester = client.Whoami(ctx) + if opts.Requester == "" { + opts.Requester = "bonfire" + } + } + + existing, err := client.GetReservation(ctx, opts.Name) + if err != nil { + return nil, fmt.Errorf("checking for existing reservation: %w", err) + } + if existing != nil { + return nil, NewFatalError("Reservation with name %s already exists", opts.Name) + } + + body := renderReservation(opts.Name, opts.Duration, opts.Requester, opts.Pool, opts.Team, opts.SecretsSourceNamespace) + if _, err = client.CreateReservation(ctx, body); err != nil { + return nil, fmt.Errorf("create reservation: %w", err) + } + + nsName, err := WaitOnReservation(ctx, client, opts.Name, opts.Timeout) + if err != nil { + // Auto-release the pending CR on timeout, matching Python behaviour. + slog.Info("timeout waiting for namespace, cancelling reservation", "name", opts.Name) + _, _ = Release(ctx, client, ReleaseOptions{Name: opts.Name}) + return nil, err + } + + res, err := client.GetReservation(ctx, opts.Name) + if err != nil { + return nil, err + } + + slog.Info("namespace reserved", + "namespace", nsName, + "requester", opts.Requester, + "duration", opts.Duration, + "pool", opts.Pool, + ) + + return &ReservationResult{ + Name: opts.Name, + Namespace: nsName, + State: nestedString(res, "status", "state"), + Expiration: nestedString(res, "status", "expiration"), + Requester: opts.Requester, + Pool: opts.Pool, + }, nil +} + +// ReleaseOptions holds parameters for Release. +type ReleaseOptions struct { + // Name is the reservation name (mutually exclusive with Namespace). + Name string + // Namespace is the namespace name to find the reservation for. + Namespace string +} + +// ReleaseResult holds the result of a release operation. +type ReleaseResult struct { + Name string + Released bool +} + +// Release releases a reservation by setting spec.duration to "0s". +// The ENO operator detects this within ~10 seconds and cascades deletion via OwnerRef. +// Mirrors bonfire_lib/reservations.py:release(). +func Release(ctx context.Context, client Client, opts ReleaseOptions) (*ReleaseResult, error) { + res, err := findReservation(ctx, client, opts.Name, opts.Namespace) + if err != nil { + return nil, err + } + + resName := nestedString(res, "metadata", "name") + if _, err = client.PatchReservation(ctx, resName, map[string]any{ + "spec": map[string]any{"duration": "0s"}, + }); err != nil { + return nil, fmt.Errorf("patch reservation %q: %w", resName, err) + } + + slog.Info("releasing reservation", "name", resName) + return &ReleaseResult{Name: resName, Released: true}, nil +} + +// ExtendResult holds the result of an extend operation. +type ExtendResult struct { + Name string + NewDuration string +} + +// Extend adds the given duration to a reservation's current duration. +// Namespace is used to look up the reservation. +// Mirrors bonfire_lib/reservations.py:extend(). +func Extend(ctx context.Context, client Client, namespace, duration string) (*ExtendResult, error) { + res, err := findReservation(ctx, client, "", namespace) + if err != nil { + return nil, err + } + + state := nestedString(res, "status", "state") + if state == "expired" { + return nil, NewFatalError("Reservation for namespace %s has expired. Reserve a new namespace.", namespace) + } + + prevSecs, err := HMSToSeconds(nestedString(res, "spec", "duration")) + if err != nil { + return nil, fmt.Errorf("invalid existing duration: %w", err) + } + addSecs, err := HMSToSeconds(duration) + if err != nil { + return nil, fmt.Errorf("invalid extend duration: %w", err) + } + newDuration := DurationFmt(prevSecs + addSecs) + + resName := nestedString(res, "metadata", "name") + if _, err = client.PatchReservation(ctx, resName, map[string]any{ + "spec": map[string]any{"duration": newDuration}, + }); err != nil { + return nil, fmt.Errorf("patch reservation %q: %w", resName, err) + } + + slog.Info("reservation extended", + "namespace", namespace, + "added", duration, + "newTotal", newDuration, + ) + return &ExtendResult{Name: resName, NewDuration: newDuration}, nil +} + +// findReservation finds a reservation by name or namespace. +// Exactly one of name/namespace must be non-empty. +func findReservation(ctx context.Context, client Client, name, namespace string) (map[string]any, error) { + switch { + case name != "": + res, err := client.GetReservation(ctx, name) + if err != nil { + return nil, err + } + if res == nil { + return nil, NewFatalError("Reservation %q not found", name) + } + return res, nil + + case namespace != "": + all, err := client.ListReservations(ctx, "") + if err != nil { + return nil, err + } + for _, res := range all { + if nestedString(res, "status", "namespace") == namespace { + return res, nil + } + } + return nil, NewFatalError("No reservation found for namespace %q", namespace) + + default: + return nil, NewFatalError("Must provide either name or namespace") + } +} + +// renderReservation builds the NamespaceReservation CR body as a map. +// Replaces the Jinja2 reservation.yaml.j2 template. +func renderReservation(name, duration, requester, pool, team, secretsSourceNamespace string) map[string]any { + spec := map[string]any{ + "duration": duration, + "requester": requester, + "pool": pool, + } + if team != "" { + spec["team"] = team + } + if secretsSourceNamespace != "" { + spec["secretSourceNamespace"] = secretsSourceNamespace + } + + return map[string]any{ + "apiVersion": "cloud.redhat.com/v1alpha1", + "kind": "NamespaceReservation", + "metadata": map[string]any{ + "name": name, + "labels": map[string]any{ + "requester": requester, + }, + }, + "spec": spec, + } +} diff --git a/go/pkg/ephemeral/reservations_test.go b/go/pkg/ephemeral/reservations_test.go new file mode 100644 index 00000000..09584d60 --- /dev/null +++ b/go/pkg/ephemeral/reservations_test.go @@ -0,0 +1,250 @@ +package ephemeral + +import ( + "context" + "strings" + "testing" +) + +func TestReserve_HappyPath(t *testing.T) { + res := sampleReservation() + calls := 0 + mc := noopClient() + mc.getReservationFn = func(_ context.Context, _ string) (map[string]any, error) { + calls++ + switch calls { + case 1: + return nil, nil // no existing reservation + case 2: + return res, nil // poll returns namespace + default: + return res, nil // final get + } + } + mc.createReservationFn = func(_ context.Context, _ map[string]any) (map[string]any, error) { + return res, nil + } + + result, err := Reserve(context.Background(), mc, ReserveOptions{ + Name: "test-reservation", + Duration: "1h", + Requester: "test-user", + Pool: "default", + Timeout: 10, + }) + mustNotErr(t, err) + if result.Name != "test-reservation" { + t.Errorf("Name = %q, want %q", result.Name, "test-reservation") + } + if result.Namespace != "ephemeral-abc123" { + t.Errorf("Namespace = %q, want %q", result.Namespace, "ephemeral-abc123") + } + if result.Requester != "test-user" { + t.Errorf("Requester = %q, want %q", result.Requester, "test-user") + } + if result.Pool != "default" { + t.Errorf("Pool = %q, want %q", result.Pool, "default") + } +} + +func TestReserve_DuplicateNameRaises(t *testing.T) { + mc := noopClient() + mc.getReservationFn = func(_ context.Context, _ string) (map[string]any, error) { + return sampleReservation(), nil // already exists + } + + _, err := Reserve(context.Background(), mc, ReserveOptions{Name: "test-reservation"}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("error %q should contain 'already exists'", err.Error()) + } +} + +func TestReserve_AutoGeneratedName(t *testing.T) { + res := sampleReservation() + calls := 0 + mc := noopClient() + mc.getReservationFn = func(_ context.Context, _ string) (map[string]any, error) { + calls++ + if calls == 1 { + return nil, nil + } + return res, nil + } + mc.createReservationFn = func(_ context.Context, _ map[string]any) (map[string]any, error) { + return res, nil + } + + result, err := Reserve(context.Background(), mc, ReserveOptions{ + Duration: "1h", + Requester: "test-user", + Timeout: 10, + }) + mustNotErr(t, err) + if !strings.HasPrefix(result.Name, "bonfire-reservation-") { + t.Errorf("Name %q should start with 'bonfire-reservation-'", result.Name) + } +} + +func TestReserve_DefaultRequesterFromWhoami(t *testing.T) { + res := sampleReservation() + calls := 0 + mc := noopClient() + mc.getReservationFn = func(_ context.Context, _ string) (map[string]any, error) { + calls++ + if calls == 1 { + return nil, nil + } + return res, nil + } + mc.createReservationFn = func(_ context.Context, _ map[string]any) (map[string]any, error) { + return res, nil + } + // Whoami default returns "test_at_user.com" from noopClient + + result, err := Reserve(context.Background(), mc, ReserveOptions{ + Name: "test-res", + Timeout: 10, + }) + mustNotErr(t, err) + if result.Requester != "test_at_user.com" { + t.Errorf("Requester = %q, want %q", result.Requester, "test_at_user.com") + } +} + +func TestRelease_ByName(t *testing.T) { + res := sampleReservation() + mc := noopClient() + mc.getReservationFn = func(_ context.Context, _ string) (map[string]any, error) { + return res, nil + } + var patchedName string + var patchedBody map[string]any + mc.patchReservationFn = func(_ context.Context, name string, patch map[string]any) (map[string]any, error) { + patchedName = name + patchedBody = patch + return res, nil + } + + result, err := Release(context.Background(), mc, ReleaseOptions{Name: "test-reservation"}) + mustNotErr(t, err) + if result.Name != "test-reservation" { + t.Errorf("Name = %q, want %q", result.Name, "test-reservation") + } + if !result.Released { + t.Error("Released should be true") + } + if patchedName != "test-reservation" { + t.Errorf("patched name = %q, want %q", patchedName, "test-reservation") + } + spec, _ := patchedBody["spec"].(map[string]any) + if spec["duration"] != "0s" { + t.Errorf("patch spec.duration = %v, want '0s'", spec["duration"]) + } +} + +func TestRelease_ByNamespace(t *testing.T) { + res := sampleReservation() + mc := noopClient() + mc.listReservationsFn = func(_ context.Context, _ string) ([]map[string]any, error) { + return []map[string]any{res}, nil + } + mc.patchReservationFn = func(_ context.Context, _ string, _ map[string]any) (map[string]any, error) { + return res, nil + } + + result, err := Release(context.Background(), mc, ReleaseOptions{Namespace: "ephemeral-abc123"}) + mustNotErr(t, err) + if result.Name != "test-reservation" { + t.Errorf("Name = %q, want %q", result.Name, "test-reservation") + } +} + +func TestRelease_UnknownNameRaises(t *testing.T) { + mc := noopClient() + mc.getReservationFn = func(_ context.Context, _ string) (map[string]any, error) { + return nil, nil + } + + _, err := Release(context.Background(), mc, ReleaseOptions{Name: "nonexistent"}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error %q should contain 'not found'", err.Error()) + } +} + +func TestRelease_NoArgsRaises(t *testing.T) { + mc := noopClient() + _, err := Release(context.Background(), mc, ReleaseOptions{}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "Must provide") { + t.Errorf("error %q should contain 'Must provide'", err.Error()) + } +} + +func TestExtend_HappyPath(t *testing.T) { + res := sampleReservation() // duration=1h, state=active + mc := noopClient() + mc.listReservationsFn = func(_ context.Context, _ string) ([]map[string]any, error) { + return []map[string]any{res}, nil + } + var patchedBody map[string]any + mc.patchReservationFn = func(_ context.Context, _ string, patch map[string]any) (map[string]any, error) { + patchedBody = patch + return res, nil + } + + result, err := Extend(context.Background(), mc, "ephemeral-abc123", "30m") + mustNotErr(t, err) + if result.Name != "test-reservation" { + t.Errorf("Name = %q, want %q", result.Name, "test-reservation") + } + if result.NewDuration != "1h30m0s" { + t.Errorf("NewDuration = %q, want %q", result.NewDuration, "1h30m0s") + } + spec, _ := patchedBody["spec"].(map[string]any) + if spec["duration"] != "1h30m0s" { + t.Errorf("patch spec.duration = %v, want '1h30m0s'", spec["duration"]) + } +} + +func TestExtend_ExpiredRaises(t *testing.T) { + expired := map[string]any{ + "metadata": map[string]any{"name": "expired-res"}, + "spec": map[string]any{"duration": "1h", "requester": "user", "pool": "default"}, + "status": map[string]any{"state": "expired", "namespace": "ephemeral-expired"}, + } + mc := noopClient() + mc.listReservationsFn = func(_ context.Context, _ string) ([]map[string]any, error) { + return []map[string]any{expired}, nil + } + + _, err := Extend(context.Background(), mc, "ephemeral-expired", "1h") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "expired") { + t.Errorf("error %q should contain 'expired'", err.Error()) + } +} + +func TestExtend_NotFoundRaises(t *testing.T) { + mc := noopClient() + mc.listReservationsFn = func(_ context.Context, _ string) ([]map[string]any, error) { + return []map[string]any{}, nil + } + + _, err := Extend(context.Background(), mc, "nonexistent", "1h") + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "No reservation found") { + t.Errorf("error %q should contain 'No reservation found'", err.Error()) + } +} diff --git a/go/pkg/ephemeral/status.go b/go/pkg/ephemeral/status.go new file mode 100644 index 00000000..926ee9f1 --- /dev/null +++ b/go/pkg/ephemeral/status.go @@ -0,0 +1,270 @@ +package ephemeral + +import ( + "context" + "encoding/base64" + "fmt" + "log/slog" + "time" +) + +// ReservationSummary is a flattened view of a NamespaceReservation CR. +type ReservationSummary struct { + Name string + Namespace string + State string + Expiration string + Requester string + Pool string + Duration string +} + +// GetReservation looks up a reservation by name, namespace, or requester. +// At most one parameter should be non-empty; returns nil, nil if not found. +// Mirrors bonfire_lib/status.py:get_reservation(). +func GetReservation(ctx context.Context, client Client, name, namespace, requester string) (map[string]any, error) { + switch { + case name != "": + return client.GetReservation(ctx, name) + + case namespace != "": + all, err := client.ListReservations(ctx, "") + if err != nil { + return nil, err + } + for _, res := range all { + if nestedString(res, "status", "namespace") == namespace { + return res, nil + } + } + return nil, nil + + case requester != "": + all, err := client.ListReservations(ctx, "requester="+requester) + if err != nil { + return nil, err + } + if len(all) == 1 { + return all[0], nil + } + if len(all) > 1 { + slog.Info("multiple reservations found for requester", "requester", requester) + } + return nil, nil + + default: + return nil, nil + } +} + +// GetReservationSummary extracts a structured summary from a raw reservation dict. +func GetReservationSummary(res map[string]any) ReservationSummary { + return ReservationSummary{ + Name: nestedString(res, "metadata", "name"), + Namespace: nestedString(res, "status", "namespace"), + State: nestedString(res, "status", "state"), + Expiration: nestedString(res, "status", "expiration"), + Requester: nestedString(res, "spec", "requester"), + Pool: nestedString(res, "spec", "pool"), + Duration: nestedString(res, "spec", "duration"), + } +} + +// ListReservations lists all reservations, optionally filtered by requester. +// Mirrors bonfire_lib/status.py:list_reservations(). +func ListReservations(ctx context.Context, client Client, requester string) ([]ReservationSummary, error) { + var selector string + if requester != "" { + selector = "requester=" + requester + } + all, err := client.ListReservations(ctx, selector) + if err != nil { + return nil, err + } + out := make([]ReservationSummary, len(all)) + for i, res := range all { + out[i] = GetReservationSummary(res) + } + return out, nil +} + +// WaitOnReservation polls a reservation until status.namespace is populated. +// Returns the assigned namespace name. +// Returns an error if timeout is exceeded (caller should release the reservation). +// Mirrors bonfire_lib/status.py:wait_on_reservation(). +func WaitOnReservation(ctx context.Context, client Client, name string, timeoutSecs int) (string, error) { + slog.Info("waiting for reservation to be picked up by operator", "name", name) + deadline := time.Now().Add(time.Duration(timeoutSecs) * time.Second) + for time.Now().Before(deadline) { + res, err := client.GetReservation(ctx, name) + if err != nil { + return "", err + } + if res != nil { + if ns := nestedString(res, "status", "namespace"); ns != "" { + return ns, nil + } + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(2 * time.Second): + } + } + return "", fmt.Errorf("timed out after %ds waiting for namespace on reservation %q", timeoutSecs, name) +} + +// CheckForExistingReservation returns true if the requester already has an active reservation +// with a live namespace. +// Mirrors bonfire_lib/status.py:check_for_existing_reservation(). +func CheckForExistingReservation(ctx context.Context, client Client, requester string) (bool, error) { + all, err := client.ListReservations(ctx, "") + if err != nil { + return false, err + } + for _, res := range all { + if nestedString(res, "spec", "requester") == requester && + nestedString(res, "status", "state") == "active" { + ns := nestedString(res, "status", "namespace") + if ns == "" { + continue + } + nsObj, err := client.GetNamespace(ctx, ns) + if err != nil { + return false, err + } + if nsObj != nil { + return true, nil + } + } + } + return false, nil +} + +// GetConsoleURL returns the OpenShift console URL from the cluster's console-public ConfigMap. +// Returns "" if not available. +// Mirrors bonfire_lib/status.py:get_console_url(). +func GetConsoleURL(ctx context.Context, client Client) string { + cm, err := client.GetConfigMap(ctx, "console-public", "openshift-config-managed") + if err != nil { + slog.Debug("unable to obtain console url", "err", err) + return "" + } + if cm == nil { + return "" + } + return cm.Data["consoleURL"] +} + +// NamespaceDescription holds detailed information about an ephemeral namespace. +type NamespaceDescription struct { + Namespace string + ConsoleNamespaceRoute string + KeycloakAdminRoute string + KeycloakAdminUsername string + KeycloakAdminPassword string + ClowdAppsDeployed int + FrontendsDeployed int + DefaultUsername string + DefaultPassword string + GatewayRoute string + HasCluster bool +} + +// DescribeNamespace returns detailed information about an ephemeral namespace. +// Mirrors bonfire_lib/status.py:describe_namespace(). +func DescribeNamespace(ctx context.Context, client Client, namespace string) (*NamespaceDescription, error) { + ns, err := client.GetNamespace(ctx, namespace) + if err != nil { + return nil, fmt.Errorf("get namespace: %w", err) + } + if ns == nil { + return nil, NewFatalError("namespace %q not found", namespace) + } + if ns.Labels["operator-ns"] != "true" { + return nil, NewFatalError("namespace %q was not reserved with namespace operator", namespace) + } + + clowdApps, err := client.ListCRDs(ctx, gvrClowdApp, namespace) + if err != nil { + slog.Warn("failed to list ClowdApps", "namespace", namespace, "err", err) + clowdApps = nil + } + + frontends, err := client.ListCRDs(ctx, gvrFrontend, namespace) + if err != nil { + slog.Warn("failed to list Frontends", "namespace", namespace, "err", err) + frontends = nil + } + + var feHost, keycloakURL string + feEnv, err := client.GetCRD(ctx, gvrFrontendEnvironment, "env-"+namespace, "") + if err != nil { + slog.Warn("failed to get FrontendEnvironment", "namespace", namespace, "err", err) + } else if feEnv != nil { + feHost = nestedString(feEnv, "spec", "hostname") + keycloakURL = nestedString(feEnv, "spec", "sso") + } + + kc := getKeycloakCreds(ctx, client, namespace) + consoleURL := GetConsoleURL(ctx, client) + nsURL := "" + if consoleURL != "" { + nsURL = consoleURL + "/k8s/cluster/projects/" + namespace + } + + hasCluster := hasClusterKubeconfig(ctx, client, namespace) + + gwRoute := "" + if feHost != "" { + gwRoute = "https://" + feHost + } + + return &NamespaceDescription{ + Namespace: namespace, + ConsoleNamespaceRoute: nsURL, + KeycloakAdminRoute: keycloakURL, + KeycloakAdminUsername: kc["username"], + KeycloakAdminPassword: kc["password"], + ClowdAppsDeployed: len(clowdApps), + FrontendsDeployed: len(frontends), + DefaultUsername: kc["defaultUsername"], + DefaultPassword: kc["defaultPassword"], + GatewayRoute: gwRoute, + HasCluster: hasCluster, + }, nil +} + +func hasClusterKubeconfig(ctx context.Context, client Client, namespace string) bool { + secret, err := client.GetSecret(ctx, namespace+"-cluster-kubeconfig", namespace) + return err == nil && secret != nil +} + +func getKeycloakCreds(ctx context.Context, client Client, namespace string) map[string]string { + result := map[string]string{ + "username": "N/A", + "password": "N/A", + "defaultUsername": "N/A", + "defaultPassword": "N/A", + } + secret, err := client.GetSecret(ctx, "env-"+namespace+"-keycloak", namespace) + if err != nil || secret == nil { + return result + } + for _, key := range []string{"username", "password", "defaultUsername", "defaultPassword"} { + raw, ok := secret.Data[key] + if !ok || len(raw) == 0 { + continue + } + // Secret data is already []byte (base64-decoded by the API server). + // But in the Python implementation it is double-decoded, so match that. + decoded, err := base64.StdEncoding.DecodeString(string(raw)) + if err != nil { + // Already raw bytes from API server + result[key] = string(raw) + } else { + result[key] = string(decoded) + } + } + return result +} diff --git a/go/pkg/ephemeral/status_test.go b/go/pkg/ephemeral/status_test.go new file mode 100644 index 00000000..b1688a2a --- /dev/null +++ b/go/pkg/ephemeral/status_test.go @@ -0,0 +1,389 @@ +package ephemeral + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestGetReservation_ByName(t *testing.T) { + res := sampleReservation() + mc := noopClient() + mc.getReservationFn = func(_ context.Context, _ string) (map[string]any, error) { + return res, nil + } + got, err := GetReservation(context.Background(), mc, "test-reservation", "", "") + mustNotErr(t, err) + if got == nil { + t.Fatal("expected non-nil result") + } + if nestedString(got, "metadata", "name") != "test-reservation" { + t.Errorf("name = %q, want %q", nestedString(got, "metadata", "name"), "test-reservation") + } +} + +func TestGetReservation_ByNameNotFound(t *testing.T) { + mc := noopClient() + mc.getReservationFn = func(_ context.Context, _ string) (map[string]any, error) { + return nil, nil + } + got, err := GetReservation(context.Background(), mc, "nonexistent", "", "") + mustNotErr(t, err) + if got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +func TestGetReservation_ByNamespace(t *testing.T) { + res := sampleReservation() + mc := noopClient() + mc.listReservationsFn = func(_ context.Context, _ string) ([]map[string]any, error) { + return []map[string]any{res}, nil + } + got, err := GetReservation(context.Background(), mc, "", "ephemeral-abc123", "") + mustNotErr(t, err) + if got == nil { + t.Fatal("expected non-nil result") + } +} + +func TestGetReservation_ByNamespaceNotFound(t *testing.T) { + mc := noopClient() + mc.listReservationsFn = func(_ context.Context, _ string) ([]map[string]any, error) { + return []map[string]any{}, nil + } + got, err := GetReservation(context.Background(), mc, "", "nonexistent", "") + mustNotErr(t, err) + if got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +func TestGetReservation_ByRequesterSingle(t *testing.T) { + res := sampleReservation() + mc := noopClient() + mc.listReservationsFn = func(_ context.Context, _ string) ([]map[string]any, error) { + return []map[string]any{res}, nil + } + got, err := GetReservation(context.Background(), mc, "", "", "test-user") + mustNotErr(t, err) + if got == nil { + t.Fatal("expected non-nil result for single reservation") + } +} + +func TestGetReservation_ByRequesterMultiple(t *testing.T) { + res := sampleReservation() + mc := noopClient() + mc.listReservationsFn = func(_ context.Context, _ string) ([]map[string]any, error) { + return []map[string]any{res, res}, nil + } + got, err := GetReservation(context.Background(), mc, "", "", "test-user") + mustNotErr(t, err) + if got != nil { + t.Error("expected nil for multiple reservations") + } +} + +func TestGetReservation_NoArgs(t *testing.T) { + mc := noopClient() + got, err := GetReservation(context.Background(), mc, "", "", "") + mustNotErr(t, err) + if got != nil { + t.Errorf("expected nil, got %v", got) + } +} + +func TestListReservations_All(t *testing.T) { + res := sampleReservation() + mc := noopClient() + mc.listReservationsFn = func(_ context.Context, _ string) ([]map[string]any, error) { + return []map[string]any{res}, nil + } + result, err := ListReservations(context.Background(), mc, "") + mustNotErr(t, err) + if len(result) != 1 { + t.Fatalf("len = %d, want 1", len(result)) + } + s := result[0] + if s.Name != "test-reservation" { + t.Errorf("Name = %q, want %q", s.Name, "test-reservation") + } + if s.Namespace != "ephemeral-abc123" { + t.Errorf("Namespace = %q", s.Namespace) + } + if s.State != "active" { + t.Errorf("State = %q", s.State) + } + if s.Requester != "test-user" { + t.Errorf("Requester = %q", s.Requester) + } + if s.Pool != "default" { + t.Errorf("Pool = %q", s.Pool) + } + if s.Duration != "1h" { + t.Errorf("Duration = %q", s.Duration) + } +} + +func TestListReservations_FilteredByRequester(t *testing.T) { + res := sampleReservation() + var gotSelector string + mc := noopClient() + mc.listReservationsFn = func(_ context.Context, sel string) ([]map[string]any, error) { + gotSelector = sel + return []map[string]any{res}, nil + } + _, err := ListReservations(context.Background(), mc, "test-user") + mustNotErr(t, err) + if gotSelector != "requester=test-user" { + t.Errorf("selector = %q, want %q", gotSelector, "requester=test-user") + } +} + +func TestWaitOnReservation_ReturnsWhenNamespaceSet(t *testing.T) { + calls := 0 + mc := noopClient() + mc.getReservationFn = func(_ context.Context, _ string) (map[string]any, error) { + calls++ + if calls == 1 { + return map[string]any{"status": map[string]any{}}, nil + } + return map[string]any{"status": map[string]any{"namespace": "ephemeral-xyz"}}, nil + } + ns, err := WaitOnReservation(context.Background(), mc, "test-res", 10) + mustNotErr(t, err) + if ns != "ephemeral-xyz" { + t.Errorf("namespace = %q, want %q", ns, "ephemeral-xyz") + } +} + +func TestWaitOnReservation_Timeout(t *testing.T) { + mc := noopClient() + mc.getReservationFn = func(_ context.Context, _ string) (map[string]any, error) { + // Simulate a sleep so the 1s timeout elapses quickly + time.Sleep(10 * time.Millisecond) + return map[string]any{"status": map[string]any{}}, nil + } + _, err := WaitOnReservation(context.Background(), mc, "test-res", 0) + if err == nil { + t.Fatal("expected timeout error") + } + if !strings.Contains(err.Error(), "timed out") { + t.Errorf("error %q should contain 'timed out'", err.Error()) + } +} + +func TestCheckForExistingReservation_HasActive(t *testing.T) { + res := sampleReservation() + mc := noopClient() + mc.listReservationsFn = func(_ context.Context, _ string) ([]map[string]any, error) { + return []map[string]any{res}, nil + } + mc.getNamespaceFn = func(_ context.Context, _ string) (*corev1.Namespace, error) { + return &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ephemeral-abc123"}}, nil + } + ok, err := CheckForExistingReservation(context.Background(), mc, "test-user") + mustNotErr(t, err) + if !ok { + t.Error("expected true") + } +} + +func TestCheckForExistingReservation_NoActive(t *testing.T) { + mc := noopClient() + mc.listReservationsFn = func(_ context.Context, _ string) ([]map[string]any, error) { + return []map[string]any{}, nil + } + ok, err := CheckForExistingReservation(context.Background(), mc, "test-user") + mustNotErr(t, err) + if ok { + t.Error("expected false") + } +} + +func TestCheckForExistingReservation_ReservationButNsGone(t *testing.T) { + res := sampleReservation() + mc := noopClient() + mc.listReservationsFn = func(_ context.Context, _ string) ([]map[string]any, error) { + return []map[string]any{res}, nil + } + mc.getNamespaceFn = func(_ context.Context, _ string) (*corev1.Namespace, error) { + return nil, nil + } + ok, err := CheckForExistingReservation(context.Background(), mc, "test-user") + mustNotErr(t, err) + if ok { + t.Error("expected false when namespace is gone") + } +} + +func TestGetConsoleURL_ReturnsURL(t *testing.T) { + mc := noopClient() + mc.getConfigMapFn = func(_ context.Context, _, _ string) (*corev1.ConfigMap, error) { + return &corev1.ConfigMap{Data: map[string]string{"consoleURL": "https://console.example.com"}}, nil + } + url := GetConsoleURL(context.Background(), mc) + if url != "https://console.example.com" { + t.Errorf("url = %q, want %q", url, "https://console.example.com") + } +} + +func TestGetConsoleURL_NotFound(t *testing.T) { + mc := noopClient() + mc.getConfigMapFn = func(_ context.Context, _, _ string) (*corev1.ConfigMap, error) { + return nil, nil + } + url := GetConsoleURL(context.Background(), mc) + if url != "" { + t.Errorf("url = %q, want empty", url) + } +} + +func TestGetConsoleURL_ExceptionReturnsEmpty(t *testing.T) { + mc := noopClient() + mc.getConfigMapFn = func(_ context.Context, _, _ string) (*corev1.ConfigMap, error) { + return nil, fmt.Errorf("connection error") + } + url := GetConsoleURL(context.Background(), mc) + if url != "" { + t.Errorf("url = %q, want empty", url) + } +} + +func TestDescribeNamespace_NotFound(t *testing.T) { + mc := noopClient() + mc.getNamespaceFn = func(_ context.Context, _ string) (*corev1.Namespace, error) { + return nil, nil + } + _, err := DescribeNamespace(context.Background(), mc, "nonexistent") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error %q should contain 'not found'", err.Error()) + } +} + +func TestDescribeNamespace_NotOperatorNs(t *testing.T) { + mc := noopClient() + mc.getNamespaceFn = func(_ context.Context, _ string) (*corev1.Namespace, error) { + return &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "regular-ns", Labels: map[string]string{}}, + }, nil + } + _, err := DescribeNamespace(context.Background(), mc, "regular-ns") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "was not reserved") { + t.Errorf("error %q should contain 'was not reserved'", err.Error()) + } +} + +func TestDescribeNamespace_ComprehensiveOutput(t *testing.T) { + mc := noopClient() + mc.getNamespaceFn = func(_ context.Context, _ string) (*corev1.Namespace, error) { + return &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ephemeral-test", + Labels: map[string]string{"operator-ns": "true"}, + }, + }, nil + } + listCallCount := 0 + mc.listCRDsFn = func(_ context.Context, gvr schema.GroupVersionResource, _ string) ([]map[string]any, error) { + listCallCount++ + if gvr.Resource == "clowdapps" { + return []map[string]any{{"metadata": map[string]any{"name": "app1"}}, {"metadata": map[string]any{"name": "app2"}}}, nil + } + return []map[string]any{{"metadata": map[string]any{"name": "fe1"}}}, nil + } + mc.getCRDFn = func(_ context.Context, _ schema.GroupVersionResource, _, _ string) (map[string]any, error) { + return map[string]any{ + "spec": map[string]any{ + "hostname": "test.example.com", + "sso": "https://keycloak.example.com", + }, + }, nil + } + mc.getSecretFn = func(_ context.Context, name, _ string) (*corev1.Secret, error) { + if strings.Contains(name, "keycloak") { + return &corev1.Secret{ + Data: map[string][]byte{ + "username": []byte("admin"), + "password": []byte("secret"), + "defaultUsername": []byte("user1"), + "defaultPassword": []byte("pass1"), + }, + }, nil + } + return nil, nil + } + mc.getConfigMapFn = func(_ context.Context, _, _ string) (*corev1.ConfigMap, error) { + return &corev1.ConfigMap{Data: map[string]string{"consoleURL": "https://console.example.com"}}, nil + } + + result, err := DescribeNamespace(context.Background(), mc, "ephemeral-test") + mustNotErr(t, err) + if result.Namespace != "ephemeral-test" { + t.Errorf("Namespace = %q", result.Namespace) + } + if result.ClowdAppsDeployed != 2 { + t.Errorf("ClowdAppsDeployed = %d, want 2", result.ClowdAppsDeployed) + } + if result.FrontendsDeployed != 1 { + t.Errorf("FrontendsDeployed = %d, want 1", result.FrontendsDeployed) + } + if result.GatewayRoute != "https://test.example.com" { + t.Errorf("GatewayRoute = %q", result.GatewayRoute) + } + if result.KeycloakAdminRoute != "https://keycloak.example.com" { + t.Errorf("KeycloakAdminRoute = %q", result.KeycloakAdminRoute) + } + if !strings.Contains(result.ConsoleNamespaceRoute, "console.example.com") { + t.Errorf("ConsoleNamespaceRoute = %q, should contain 'console.example.com'", result.ConsoleNamespaceRoute) + } +} + +func TestDescribeNamespace_NoKeycloakSecret(t *testing.T) { + mc := noopClient() + mc.getNamespaceFn = func(_ context.Context, _ string) (*corev1.Namespace, error) { + return &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ephemeral-test", + Labels: map[string]string{"operator-ns": "true"}, + }, + }, nil + } + mc.listCRDsFn = func(_ context.Context, _ schema.GroupVersionResource, _ string) ([]map[string]any, error) { + return []map[string]any{}, nil + } + mc.getCRDFn = func(_ context.Context, _ schema.GroupVersionResource, _, _ string) (map[string]any, error) { + return nil, nil + } + mc.getSecretFn = func(_ context.Context, _, _ string) (*corev1.Secret, error) { + return nil, nil + } + mc.getConfigMapFn = func(_ context.Context, _, _ string) (*corev1.ConfigMap, error) { + return nil, nil + } + + result, err := DescribeNamespace(context.Background(), mc, "ephemeral-test") + mustNotErr(t, err) + if result.KeycloakAdminUsername != "N/A" { + t.Errorf("KeycloakAdminUsername = %q, want N/A", result.KeycloakAdminUsername) + } + if result.GatewayRoute != "" { + t.Errorf("GatewayRoute = %q, want empty", result.GatewayRoute) + } + if result.ConsoleNamespaceRoute != "" { + t.Errorf("ConsoleNamespaceRoute = %q, want empty", result.ConsoleNamespaceRoute) + } +} diff --git a/go/pkg/ephemeral/utils.go b/go/pkg/ephemeral/utils.go new file mode 100644 index 00000000..c642f41e --- /dev/null +++ b/go/pkg/ephemeral/utils.go @@ -0,0 +1,147 @@ +// Package ephemeral provides the bonfire_lib Go port: namespace and cluster +// reservation lifecycle against cloud.redhat.com/v1alpha1 CRDs. +package ephemeral + +import ( + "fmt" + "regexp" + "strconv" +) + +// FatalError represents a logical error that should stop the caller. +type FatalError struct { + msg string +} + +func (e *FatalError) Error() string { return e.msg } + +// NewFatalError creates a FatalError with the given message. +func NewFatalError(format string, args ...any) *FatalError { + return &FatalError{msg: fmt.Sprintf(format, args...)} +} + +var dnsLabelRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$`) + +// ValidateDNSName validates that name conforms to DNS-1123 label rules. +// Rules: lowercase alphanumeric + hyphens, 1–63 chars, must start and end +// with alphanumeric. Returns the name unchanged if valid. +func ValidateDNSName(name string) (string, error) { + if name == "" || !dnsLabelRE.MatchString(name) { + return "", fmt.Errorf("invalid name %q: must be a DNS-1123 label (lowercase alphanumeric + hyphens, 1-63 chars)", name) + } + return name, nil +} + +var durationRE = regexp.MustCompile(`^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$`) + +// HMSToSeconds converts a duration string (e.g. "1h30m", "45m", "3600s") to seconds. +// Returns an error for empty or invalid strings. "0s" returns 0 without error. +func HMSToSeconds(s string) (int, error) { + if s == "" { + return 0, fmt.Errorf("duration string cannot be empty") + } + m := durationRE.FindStringSubmatch(s) + if m == nil || (m[1] == "" && m[2] == "" && m[3] == "") { + return 0, fmt.Errorf("invalid duration format: %q", s) + } + secs := 0 + if m[1] != "" { + h, _ := strconv.Atoi(m[1]) + secs += h * 3600 + } + if m[2] != "" { + mn, _ := strconv.Atoi(m[2]) + secs += mn * 60 + } + if m[3] != "" { + sc, _ := strconv.Atoi(m[3]) + secs += sc + } + return secs, nil +} + +// DurationFmt converts seconds to a duration string (e.g. "1h30m0s"). +// This is the inverse of HMSToSeconds. +func DurationFmt(seconds int) string { + h := seconds / 3600 + seconds %= 3600 + m := seconds / 60 + seconds %= 60 + if h > 0 { + return fmt.Sprintf("%dh%dm%ds", h, m, seconds) + } else if m > 0 { + return fmt.Sprintf("%dm%ds", m, seconds) + } + return fmt.Sprintf("%ds", seconds) +} + +// PrettyTimeDelta formats seconds as a human-readable delta (e.g. "2d3h15m0s"). +func PrettyTimeDelta(seconds int) string { + d := seconds / 86400 + seconds %= 86400 + h := seconds / 3600 + seconds %= 3600 + m := seconds / 60 + seconds %= 60 + if d > 0 { + return fmt.Sprintf("%dd%dh%dm%ds", d, h, m, seconds) + } else if h > 0 { + return fmt.Sprintf("%dh%dm%ds", h, m, seconds) + } else if m > 0 { + return fmt.Sprintf("%dm%ds", m, seconds) + } + return fmt.Sprintf("%ds", seconds) +} + +var validateFmt = regexp.MustCompile(`^((\d+)h)?((\d+)m)?((\d+)s)?$`) + +const ( + minDurationSecs = 1800 // 30 minutes + maxDurationSecs = 1209600 // 14 days +) + +// ValidateTimeString validates a duration string format and range. +// Must be in h/m/s format (e.g. "1h30m"), between 30 minutes and 14 days. +func ValidateTimeString(s string) (string, error) { + if !validateFmt.MatchString(s) { + return "", fmt.Errorf("invalid format for duration %q, expecting h/m/s string. Ex: '1h30m'", s) + } + secs, err := HMSToSeconds(s) + if err != nil { + return "", fmt.Errorf("invalid format for duration %q, expecting h/m/s string. Ex: '1h30m'", s) + } + if secs > maxDurationSecs { + return "", fmt.Errorf("invalid duration %q, must be less than 14 days", s) + } + if secs < minDurationSecs { + return "", fmt.Errorf("invalid duration %q, must be more than 30 mins", s) + } + return s, nil +} + +// sanitizeUsername replaces @ with _at_ and : with _ for use as a K8s label value. +func sanitizeUsername(name string) string { + out := make([]byte, 0, len(name)) + for i := 0; i < len(name); i++ { + switch name[i] { + case '@': + out = append(out, []byte("_at_")...) + case ':': + out = append(out, '_') + default: + out = append(out, name[i]) + } + } + return string(out) +} + +// extractUsername extracts the username from a kubeconfig context user string. +// e.g. "gbuchana/api-crc-eph.com:6443" → "gbuchana" +func extractUsername(contextUser string) string { + for i := 0; i < len(contextUser); i++ { + if contextUser[i] == '/' { + return contextUser[:i] + } + } + return contextUser +} diff --git a/go/pkg/ephemeral/utils_test.go b/go/pkg/ephemeral/utils_test.go new file mode 100644 index 00000000..4b28bc65 --- /dev/null +++ b/go/pkg/ephemeral/utils_test.go @@ -0,0 +1,180 @@ +package ephemeral + +import ( + "testing" +) + +func TestSanitizeUsername(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"at sign", "user@redhat.com", "user_at_redhat.com"}, + {"colon", "system:admin", "system_admin"}, + {"both", "u@r:c", "u_at_r_c"}, + {"no change", "plainuser", "plainuser"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sanitizeUsername(tt.input); got != tt.want { + t.Errorf("sanitizeUsername(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestExtractUsername(t *testing.T) { + tests := []struct { + name string + contextUser string + want string + }{ + {"with cluster url", "gbuchana/api-crc-eph-r9lp-p1-openshiftapps-com:6443", "gbuchana"}, + {"simple url", "admin/api.example.com:6443", "admin"}, + {"plain username", "gbuchana", "gbuchana"}, + {"email style", "user@redhat.com", "user@redhat.com"}, + {"multiple slashes", "user/host/extra", "user"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := extractUsername(tt.contextUser); got != tt.want { + t.Errorf("extractUsername(%q) = %q, want %q", tt.contextUser, got, tt.want) + } + }) + } +} + +func TestHMSToSeconds(t *testing.T) { + tests := []struct { + name string + input string + want int + wantErr bool + }{ + {"hours only", "1h", 3600, false}, + {"minutes only", "30m", 1800, false}, + {"seconds only", "90s", 90, false}, + {"hours and minutes", "1h30m", 5400, false}, + {"all units", "1h0m30s", 3630, false}, + {"zero", "0s", 0, false}, + {"large", "24h", 86400, false}, + {"empty string", "", 0, true}, + {"invalid", "abc", 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := HMSToSeconds(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("HMSToSeconds(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + return + } + if !tt.wantErr && got != tt.want { + t.Errorf("HMSToSeconds(%q) = %d, want %d", tt.input, got, tt.want) + } + }) + } +} + +func TestDurationFmt(t *testing.T) { + tests := []struct { + name string + seconds int + want string + }{ + {"hours minutes seconds", 5400, "1h30m0s"}, + {"minutes seconds", 90, "1m30s"}, + {"seconds only", 45, "45s"}, + {"zero", 0, "0s"}, + {"exact hour", 3600, "1h0m0s"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := DurationFmt(tt.seconds); got != tt.want { + t.Errorf("DurationFmt(%d) = %q, want %q", tt.seconds, got, tt.want) + } + }) + } +} + +func TestDurationRoundtrip(t *testing.T) { + secs := 5400 + formatted := DurationFmt(secs) + got, err := HMSToSeconds(formatted) + if err != nil { + t.Fatalf("HMSToSeconds(%q) error: %v", formatted, err) + } + if got != secs { + t.Errorf("roundtrip failed: %d → %q → %d", secs, formatted, got) + } +} + +func TestPrettyTimeDelta(t *testing.T) { + tests := []struct { + name string + seconds int + want string + }{ + {"with days", 90061, "1d1h1m1s"}, + {"hours", 3661, "1h1m1s"}, + {"minutes", 61, "1m1s"}, + {"seconds", 5, "5s"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := PrettyTimeDelta(tt.seconds); got != tt.want { + t.Errorf("PrettyTimeDelta(%d) = %q, want %q", tt.seconds, got, tt.want) + } + }) + } +} + +func TestValidateDNSName(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + {"valid hyphen", "my-reservation", false}, + {"valid alnum", "abc123", false}, + {"valid single char", "a", false}, + {"invalid uppercase", "INVALID", true}, + {"invalid starts with hyphen", "-invalid", true}, + {"invalid ends with hyphen", "invalid-", true}, + {"invalid too long", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true}, // 64 chars + {"invalid underscore", "invalid_name", true}, + {"empty", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ValidateDNSName(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateDNSName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + }) + } +} + +func TestValidateTimeString(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + errContains string + }{ + {"valid 1h", "1h", false, ""}, + {"valid 1h30m", "1h30m", false, ""}, + {"valid 45m", "45m", false, ""}, + {"invalid format", "abc", true, "invalid format"}, + {"too short", "10m", true, "must be more than 30 mins"}, + {"too long", "360h", true, "must be less than 14 days"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ValidateTimeString(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateTimeString(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + }) + } +}