diff --git a/ACM-42568_go_migration/.gitignore b/ACM-42568_go_migration/.gitignore new file mode 100644 index 0000000000..04b46ce8d8 --- /dev/null +++ b/ACM-42568_go_migration/.gitignore @@ -0,0 +1,6 @@ +# Copyright Contributors to the Open Cluster Management project + +testdata/recorded/ +*.exe +*.test +coverage.out diff --git a/ACM-42568_go_migration/QUIRKS.md b/ACM-42568_go_migration/QUIRKS.md new file mode 100644 index 0000000000..23d5a7cc99 --- /dev/null +++ b/ACM-42568_go_migration/QUIRKS.md @@ -0,0 +1,128 @@ +# Known Node backend quirks (ACM-42590) + +Documented so Go can **replicate** them on purpose, or **fix later** with an explicit decision. Contract tests encode the current Node wire behavior. + +## Replicate (bug-compatible / observable contract) + +### DELETED SSE events skip RBAC + +`eventFilter` in `backend/src/routes/events.ts` returns `true` for every `DELETED` event. ADDED/MODIFIED go through SelfSubjectAccessReview. Comment in source: namespace deletion makes a later access check fail. + +**Decision:** replicate until a dedicated security follow-up tracks who previously received the object. + +### SSAR cache TTL 60s + +Per-token access cache: TTL 60s, cleanup interval 90s, max 1000 tokens (`ACCESS_CACHE_TTL` in `events.ts`). + +**Decision:** replicate. Tests do not assert wall-clock TTL (flake); they assert authorized vs unauthorized snapshots. + +### Kube proxy header allowlist + +`proxy.ts` forwards only `accept`, `accept-encoding`, `content-encoding`, `content-length`, `content-type` to the API server, and only `cache-control`, `content-type`, `content-length`, `content-encoding`, `etag` back to the client. Cookies and `X-Forwarded-*` are not passed through. + +**Decision:** replicate. Tests assert `content-type` is present and `set-cookie` is absent on `/api`. + +### Session probe is GET /api, not TokenReview + +`isAuthenticated` uses `GET {CLUSTER_API_URL}/api` with the user Bearer token. `/username` and `/userpreference` use TokenReview. + +**Decision:** replicate. `/authenticated` returns that status with an empty body. + +### OAuth routes only when NODE_ENV !== production + +`/configure`, `/login`, `/login/callback`, `/logout` are registered only outside production. + +**Decision:** replicate. Local `npm run plugins` is development, so these cases are required there. + +### 401 / 404 / 500 bodies are empty + +`unauthorized`, `notFound`, and `respondInternalServerError` write status with no JSON body. + +**Decision:** replicate (`bodyEmpty: true` on negative cases). + +### Search WebSocket injects Authorization in `connection_init` + +The browser graphql-ws `connection_init` payload is rewritten to include `Authorization: Bearer ` before relay to search-api. The HTTP POST `/proxy/search` sends Bearer on the outbound request. + +**Decision:** replicate. WS test sends `connection_init` without a token and expects `connection_ack` if Search is up. + +### userpreference username sanitization + +TokenReview username is lowercased and non `[a-z0-9-.]` characters become `-` for the CR name. + +**Decision:** replicate. Tests only assert GET returns JSON. + +### Metrics path rewrite + +`/observability/*` and `/prometheus/*` replace the first path segment with `/api/v1` before proxying. + +**Decision:** replicate. + +### Authentication CR is not fanned out on SSE + +`{ kind: 'Authentication', apiVersion: 'config.openshift.io/v1', forwardEventsToClients: false }`. Hub still reads it for `/hub`. The informer cache (ACM-42597) still stores it; snapshot compare **includes** Authentication and **excludes** Argo `isPolled` kinds. + +**Decision:** replicate. Watched-kind set used by SSE tests omits Authentication. + +### SSE framing, packets, compression, keepalive + +- `id:` + `data:{json}` + blank line +- Snapshot order: START, SETTINGS, then ManagedCluster/HostedCluster/… packets ending with `EOP`, remainder, LOADED +- Gzip (not brotli; Firefox) unless `DISABLE_STREAM_COMPRESSION=true` +- Keepalive comment `:\n\n` every 10s +- `Set-Cookie: watch=; Secure; HttpOnly; Path=/` +- `Content-Type: text/event-stream`, `Cache-Control: no-store, no-transform` + +**Decision:** replicate framing and lifecycle types. Tests do not require exact ADDED ordering inside a packet, only type presence and object shape. + +### `/multicloud` prefix + +HTTP `requestHandler` strips `/multicloud` before `find-my-way`. WebSocket `upgrade` only matches `/multicloud/proxy/search` and `/multicloud/managedclusterproxy` — a bare `/proxy/search` upgrade is ignored and the socket hangs. + +**Decision:** replicate HTTP stripping. WS tests use the `/multicloud` path the plugin actually opens. + +### Development `admin-token` cache + +`getToken()` in `NODE_ENV=development` reads `admin-token` from `node-localstorage` under `./certs` when the request has no cookie and no `Authorization`. `getAuthenticatedToken` writes that key after a successful `GET /api`. Local `npm run plugins` therefore treats a no-token request as kubeadmin after the first login. + +**Decision:** do not replicate in Go (dev-only). Negative catalog cases send `Authorization: Bearer acm-42590-invalid-token` (`auth: invalid`) so they 401 in both development and production. Missing-token 401 is production-only. + +### CORS only in non-production + +`cors.ts` reflects `Origin`, `Access-Control-Allow-Credentials: true`, and answers OPTIONS with 200. + +**Decision:** replicate for standalone/dev. Production plugin traffic is same-origin through Console. + +### find-my-way maxParamLength 500 + +Long Kubernetes names need this; unmatched routes 404. + +**Decision:** replicate. + +## Fix later (do not block migration) + +- DELETED without access check (security). +- Empty error bodies (harder to debug; changing them would break clients that treat any body as JSON). +- Development `admin-token` file cache (do not port). +- Webpack devServer missing `/apiPaths`, `/cluster-version`, `/placement-debug` (plugin proxy is prefix-based so production plugins are fine). +- Dead webpack entries `/multicloud/common`, `/multicloud/console-links` (not backend routes). + +## Plugin proxy `authorize: true` and hub alignment + +OpenShift Console on :9000 proxies to `https://localhost:4000` with `authorize: true`. The user token is validated against `CLUSTER_API_URL`. If that URL does not match `oc whoami --show-server`, authenticated routes 401 and the UI redirects to `/dashboards`. + +**Decision:** not a backend bug. `run-catalog.sh` preflight (and `scripts/check-hub-alignment.sh` when present) fail fast. Fix: `npm run setup:hub`. + +## TLS on :4000 + +Plugin proxy and contract tests use `https://localhost:4000`. Missing `backend/certs/` makes Go serve HTTP; Console logs `tls: first record does not look like a TLS handshake`. + +**Decision:** preflight requires certs when the backend URL is HTTPS. `npm run generate-certs` then restart `npm run plugins`. + +## Not in Node (do not require against sidecar-only) + +`GET /events/rbac` is Go-owned (never served by Node). Catalog case `events-rbac-sse` is **soft** and skips on 404 so a sidecar-only run stays green. + +## SSE / WebSocket compare + +`CONTRACT_COMPARE_URL` diffs **REST only**. SSE and WebSocket are skipped on purpose (`shouldCompareREST`). A Node-vs-Go SSE shadow-diff (including multi-role RBAC) is **ACM-42598**. The hook is already isolated so 42598 can enable it without changing REST compare. diff --git a/ACM-42568_go_migration/README.md b/ACM-42568_go_migration/README.md new file mode 100644 index 0000000000..90c167cf0b --- /dev/null +++ b/ACM-42568_go_migration/README.md @@ -0,0 +1,144 @@ +# Contract test framework (ACM-42590) + +Isolated Go module. Black-box HTTP client against the running console backend (Node today, Go later). Not wired into root `package.json` or CI. + +## Quick start (npm run plugins already running) + +With `npm run plugins` up and `oc login` done: + +```bash +cd ACM-42568_go_migration +./run-catalog.sh +``` + +Or manually: + +```bash +cd ACM-42568_go_migration +export CONTRACT_BACKEND_URL=https://localhost:4000 +export CONTRACT_TOKEN=$(oc whoami -t) +go test . -count=1 -timeout 15m -v -run TestCatalogAgainstBackend +``` + +**You only need the backend on :4000** (started by `npm run plugins`). You do **not** need OpenShift Console on :9000. + +Quick check: `curl -sk https://localhost:4000/ping` → `200`. + +`./run-catalog.sh` runs a **preflight** before tests: hub alignment (`oc whoami --show-server` vs `CLUSTER_API_URL` in `backend/.env`), TLS certs for HTTPS, and `GET /ping`. `CONTRACT_SKIP_PREFLIGHT=1` disables it. + +At the end: colored summary — **Executed**, **OK** (green), **SOFT** (yellow), **FAIL** (red). **FAIL must be 0** for a green gate. **SOFT** skips are optional upstreams missing on your hub (normal on dev). + +`./run-catalog.sh` prints per-case progress by default (`CONTRACT_VERBOSE=1`). A full run can take several minutes when hub proxies time out (30s per slow case). Set `CONTRACT_VERBOSE=0` for summary-only output. `NO_COLOR=1` disables colors. + +## Prerequisites + +- Hub: `oc login` +- Backend: `npm run plugins` from repo root (Go on `https://localhost:4000`, Node sidecar on `:4001`) +- Go 1.22+ +- `backend/certs/` present (`npm run generate-certs`) when using HTTPS + +## Troubleshooting + +### Plugin UI redirects to `/dashboards` + +`oc whoami --show-server` must match `CLUSTER_API_URL` in `backend/.env`. After `oc login` to a new hub, run `npm run setup:hub` and restart `npm run plugins`. The OpenShift Console plugin proxy on :9000 sends your token with `authorize: true`; a hub mismatch 401s authenticated routes and the frontend logs out. + +### `./run-catalog.sh` appears stuck (no output for minutes) + +The script runs **~118 HTTP checks** sequentially. Without per-case output it looks frozen. As of the latest `run-catalog.sh`, progress lines print by default (`CONTRACT_VERBOSE=1`). A full run can still take **10–20+ minutes** when hub proxies or aggregate search hit their per-case timeout (30–60s each). + +```bash +# Subset while working on long-tail (ACM-42602): +CONTRACT_GROUP=long-tail ./run-catalog.sh + +# Faster timeouts (more SOFT skips on a slow hub): +CONTRACT_HTTP_TIMEOUT=20 ./run-catalog.sh +``` + +If **every** case hits `context deadline exceeded`, restart `npm run plugins` so Go serves the migrated routes (long-tail no longer lives on the Node sidecar). Confirm `curl -sk https://localhost:4000/ping` → `200` and `oc whoami -t` is non-empty. + +### `tls: first record does not look like a TLS handshake` + +`backend/certs/` is missing, or backends started before certs existed. Run `npm run generate-certs` and restart **both** Go and the Node sidecar (certs are read only at startup). + +## What each layer validates + +| Layer | Command | Gate for | +|-------|---------|----------| +| REST catalog | `./run-catalog.sh` | Phases 1–2 and later REST migrations. **FAIL: 0** | +| SSE `GET /events` | catalog `events-sse` | **ACM-42598** (still Node today) | +| SSE `GET /events/rbac` | catalog `events-rbac-sse` (soft) | Already Go; 404 skip if hitting sidecar-only | +| Watch spec parity | `go test -run TestWatchedResourcesMatchEventsTS` | YAML vs `events.ts` `definitions` | +| Cache snapshot | `./compare-informer-cache.sh` | **ACM-42597** informer cache | + +## Snapshot harness (ACM-42597) + +Compares normalized keys `{apiVersion,kind,namespace,name}`. Argo `polled` kinds are excluded. Authentication **is** included (cached, not fanned out on SSE). + +```bash +cd ACM-42568_go_migration +./compare-informer-cache.sh +``` + +The test **skips** (not fail) when `GET /debug/informer-snapshot` is missing (Go cache not wired yet). After ACM-42597: + +Go informers start **after** `:4000` is bound. `CONSOLE_INFORMER_CACHE=0` disables them. After sync, the Go process logs `informer cache memory` with `heapAlloc` — compare that to the sidecar `INFO:memory` `eventCache` size, not combined RSS. The Go store is uncompressed `unstructured` (managedFields stripped except Policy). + +| Variable | Purpose | +|----------|---------| +| `CONTRACT_GO_SNAPSHOT_URL` | Default `{BACKEND}/debug/informer-snapshot` | +| `CONTRACT_NODE_SNAPSHOT_URL` | Optional Node dump URL | +| `CONTRACT_GO_SNAPSHOT_FILE` / `CONTRACT_NODE_SNAPSHOT_FILE` | JSON `{ "items": [...] }` | + +Offline unit tests (`TestDiffSnapshots`, `TestExcludePolled`) always run. + +## Alternative: through the OCP Console proxy + +Only if you also run the full plugin stack with Console on :9000: + +```bash +cd ACM-42568_go_migration +export CONTRACT_BACKEND_URL=http://localhost:9000 +export CONTRACT_PATH_PREFIX=/api/proxy/plugin/mce/console/multicloud +export CONTRACT_TOKEN=$(oc whoami -t) +go test . -count=1 -timeout 15m +``` + +For day-to-day contract runs, use **:4000** (quick start above). + +## Modes + +| Env | Effect | +|-----|--------| +| `CONTRACT_MODE=assert` (default) | Check status, headers, JSON shape, SSE framing, WS upgrade | +| `CONTRACT_COMPARE_URL=https://...` | Replay **REST** cases against a second backend and diff. **Does not diff SSE or WebSocket** (ACM-42598 will add a shadow-diff). | +| `CONTRACT_RECORD=1` | Write captures under `testdata/recorded/` (gitignored) | + +Other: `CONTRACT_SSE_TIMEOUT` (default 120s), `CONTRACT_HTTP_TIMEOUT` (default 60s), `CONTRACT_TLS_INSECURE` (default true), `CONTRACT_RECORD_DIR`. + +## Catalog + +YAML in `catalog/` — add a route = add a YAML case. + +- `soft: true` — skip (not fail) when optional upstream is missing +- `alsoMulticloud: true` — also run with `/multicloud` prefix +- `kind: sse` / `kind: websocket` — streaming cases +- `auth: invalid` — fake Bearer for 401 cases + +`catalog/watched-resources.yaml` is the watch-spec catalog: one entry per `events.ts` definition, including `labelSelector`, `fieldSelector`, `polled`, and `forwardEventsToClients`. `source: events-rbac` marks the Go-only ClusterRole informer. + +See `QUIRKS.md` for Node behaviors Go should replicate. + +## Layout + +```text +ACM-42568_go_migration/ +├── catalog/ +├── run-catalog.sh # preflight + full catalog + colored summary +├── compare-informer-cache.sh # ACM-42597 snapshot gate +├── testdata/recorded/ # gitignored +├── *.go +├── go.mod +├── README.md +└── QUIRKS.md +``` diff --git a/ACM-42568_go_migration/assert.go b/ACM-42568_go_migration/assert.go new file mode 100644 index 0000000000..d3644ecef9 --- /dev/null +++ b/ACM-42568_go_migration/assert.go @@ -0,0 +1,169 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +package contract + +import ( + "encoding/json" + "fmt" + "strings" +) + +func AssertCapture(cs Case, cap Capture, watched map[string]struct{}) error { + if containsInt(cs.SoftStatuses, cap.Status) && cs.Soft { + return skipSoft(fmt.Sprintf("status %d is in softStatuses", cap.Status)) + } + if !containsInt(cs.Expect.Status, cap.Status) { + if cs.Soft { + return skipSoft(fmt.Sprintf("status %d not in %v", cap.Status, cs.Expect.Status)) + } + return fmt.Errorf("status %d, want one of %v; body=%s", cap.Status, cs.Expect.Status, truncate(cap.Decoded, 400)) + } + + headers := cap.HeaderMap() + exp := cs.Expect + + if exp.ContentTypeContains != "" { + ct := FirstHeader(headers, "content-type") + if !strings.Contains(strings.ToLower(ct), strings.ToLower(exp.ContentTypeContains)) { + return fmt.Errorf("content-type %q does not contain %q", ct, exp.ContentTypeContains) + } + } + for _, name := range exp.HeaderPresent { + if FirstHeader(headers, name) == "" { + return fmt.Errorf("missing header %s", name) + } + } + for _, name := range exp.HeaderAbsent { + if FirstHeader(headers, name) != "" { + return fmt.Errorf("header %s should be absent, got %q", name, FirstHeader(headers, name)) + } + } + for name, want := range exp.HeaderEquals { + got := FirstHeader(headers, name) + if !strings.EqualFold(got, want) && got != want { + return fmt.Errorf("header %s = %q, want %q", name, got, want) + } + } + for _, needle := range exp.SetCookieContains { + found := false + for _, c := range HeaderValuesCI(headers, "set-cookie") { + if strings.Contains(c, needle) { + found = true + break + } + } + if !found { + return fmt.Errorf("Set-Cookie missing %q; got %v", needle, HeaderValuesCI(headers, "set-cookie")) + } + } + + body := cap.Decoded + if exp.BodyEmpty { + if len(bytesTrim(body)) != 0 { + return fmt.Errorf("expected empty body, got %q", truncate(body, 200)) + } + return nil + } + + if len(exp.JSONKeys) > 0 || exp.JSONType != "" || len(exp.JSONPathEquals) > 0 { + var parsed any + if err := json.Unmarshal(body, &parsed); err != nil { + return fmt.Errorf("json body: %w; body=%s", err, truncate(body, 300)) + } + if exp.JSONType == "object" { + if _, ok := parsed.(map[string]any); !ok { + return fmt.Errorf("json type %T, want object", parsed) + } + } + if exp.JSONType == "array" { + if _, ok := parsed.([]any); !ok { + return fmt.Errorf("json type %T, want array", parsed) + } + } + for _, key := range exp.JSONKeys { + obj, ok := parsed.(map[string]any) + if !ok { + return fmt.Errorf("json keys require object, got %T", parsed) + } + if _, exists := obj[key]; !exists { + return fmt.Errorf("missing json key %q", key) + } + } + for path, want := range exp.JSONPathEquals { + got, err := jsonPathString(parsed, path) + if err != nil { + return err + } + if got != want { + return fmt.Errorf("json path %s = %q, want %q", path, got, want) + } + } + } + + if exp.SSE != nil { + events, err := ParseSSE(body) + if err != nil { + return err + } + if err := AssertSSE(exp.SSE, events, watched); err != nil { + return err + } + } + return nil +} + +type softSkip struct{ reason string } + +func (s softSkip) Error() string { return s.reason } + +func skipSoft(reason string) error { return softSkip{reason: reason} } + +func IsSoftSkip(err error) bool { + _, ok := err.(softSkip) + return ok +} + +func containsInt(list []int, n int) bool { + for _, v := range list { + if v == n { + return true + } + } + return false +} + +func bytesTrim(b []byte) []byte { + return []byte(strings.TrimSpace(string(b))) +} + +func truncate(b []byte, n int) string { + s := string(b) + if len(s) > n { + return s[:n] + "…" + } + return s +} + +func jsonPathString(v any, path string) (string, error) { + cur := v + for _, part := range strings.Split(path, ".") { + obj, ok := cur.(map[string]any) + if !ok { + return "", fmt.Errorf("json path %s: not an object at %s", path, part) + } + next, ok := obj[part] + if !ok { + return "", fmt.Errorf("json path %s: missing %s", path, part) + } + cur = next + } + switch t := cur.(type) { + case string: + return t, nil + case json.Number: + return t.String(), nil + default: + b, _ := json.Marshal(t) + return string(b), nil + } +} diff --git a/ACM-42568_go_migration/catalog.go b/ACM-42568_go_migration/catalog.go new file mode 100644 index 0000000000..509b82fa6f --- /dev/null +++ b/ACM-42568_go_migration/catalog.go @@ -0,0 +1,155 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +package contract + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +type CatalogFile struct { + Cases []Case `yaml:"cases"` + Resources []Resource `yaml:"resources"` +} + +type Resource struct { + Kind string `yaml:"kind"` + APIVersion string `yaml:"apiVersion"` + Polled bool `yaml:"polled"` + ForwardEventsToClients *bool `yaml:"forwardEventsToClients"` + LabelSelector map[string]string `yaml:"labelSelector"` + FieldSelector map[string]string `yaml:"fieldSelector"` + // Source is "events" (default, events.ts definitions) or "events-rbac" (Go GET /events/rbac). + Source string `yaml:"source"` +} + +func (r Resource) EventsSource() bool { + return r.Source == "" || r.Source == "events" +} + +func (r Resource) ForwardsToClients() bool { + if r.ForwardEventsToClients == nil { + return true + } + return *r.ForwardEventsToClients +} + +func (r Resource) SpecKey() string { + return r.APIVersion + "|" + r.Kind + "|" + SelectorQuery(r.LabelSelector) + "|" + SelectorQuery(r.FieldSelector) +} + +type Case struct { + ID string `yaml:"id"` + Group string `yaml:"group"` + Kind string `yaml:"kind"` // rest (default), sse, websocket + Description string `yaml:"description"` + Method string `yaml:"method"` + Path string `yaml:"path"` + Auth string `yaml:"auth"` // none, bearer, cookie, both, invalid + Headers map[string]string `yaml:"headers"` + Body any `yaml:"body"` + RawBody string `yaml:"rawBody"` + ContentType string `yaml:"contentType"` + AlsoMulticloud bool `yaml:"alsoMulticloud"` + Soft bool `yaml:"soft"` + SoftStatuses []int `yaml:"softStatuses"` + Expect Expect `yaml:"expect"` + TimeoutSeconds int `yaml:"timeoutSeconds"` + WS *WSSpec `yaml:"ws"` +} + +type Expect struct { + Status []int `yaml:"status"` + BodyEmpty bool `yaml:"bodyEmpty"` + ContentTypeContains string `yaml:"contentTypeContains"` + HeaderPresent []string `yaml:"headerPresent"` + HeaderEquals map[string]string `yaml:"headerEquals"` + HeaderAbsent []string `yaml:"headerAbsent"` + JSONKeys []string `yaml:"jsonKeys"` + JSONType string `yaml:"jsonType"` + JSONPathEquals map[string]string `yaml:"jsonPathEquals"` + SetCookieContains []string `yaml:"setCookieContains"` + SSE *SSEExpect `yaml:"sse"` +} + +type SSEExpect struct { + FirstType string `yaml:"firstType"` + LastType string `yaml:"lastType"` + RequireTypes []string `yaml:"requireTypes"` + ObjectHasKindAPIVersionName bool `yaml:"objectHasKindApiVersionName"` + KindsSubsetOfWatched bool `yaml:"kindsSubsetOfWatched"` + SettingsKeys []string `yaml:"settingsKeys"` +} + +type WSSpec struct { + Subprotocol string `yaml:"subprotocol"` + Send []string `yaml:"send"` + ExpectType string `yaml:"expectType"` + ExpectUpgrade bool `yaml:"expectUpgrade"` +} + +func LoadCatalog(dir string) ([]Case, []Resource, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, nil, err + } + var cases []Case + var resources []Resource + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { + continue + } + path := filepath.Join(dir, e.Name()) + b, err := os.ReadFile(path) + if err != nil { + return nil, nil, fmt.Errorf("%s: %w", path, err) + } + var file CatalogFile + if err := yaml.Unmarshal(b, &file); err != nil { + return nil, nil, fmt.Errorf("%s: %w", path, err) + } + for i := range file.Cases { + c := file.Cases[i] + if c.Kind == "" { + c.Kind = "rest" + } + if c.Method == "" { + c.Method = "GET" + } + if len(c.Expect.Status) == 0 { + c.Expect.Status = []int{200} + } + cases = append(cases, c) + } + resources = append(resources, file.Resources...) + } + if len(cases) == 0 { + return nil, nil, fmt.Errorf("no cases loaded from %s", dir) + } + return cases, resources, nil +} + +func WatchedKindSet(resources []Resource) map[string]struct{} { + out := make(map[string]struct{}) + for _, r := range resources { + if !r.EventsSource() || !r.ForwardsToClients() { + continue + } + out[r.Kind] = struct{}{} + } + return out +} + +func EventsWatchSpecs(resources []Resource) []Resource { + out := make([]Resource, 0, len(resources)) + for _, r := range resources { + if r.EventsSource() { + out = append(out, r) + } + } + return out +} diff --git a/ACM-42568_go_migration/catalog/00-probes.yaml b/ACM-42568_go_migration/catalog/00-probes.yaml new file mode 100644 index 0000000000..295f7eefd3 --- /dev/null +++ b/ACM-42568_go_migration/catalog/00-probes.yaml @@ -0,0 +1,34 @@ +# Copyright Contributors to the Open Cluster Management project +cases: + - id: ping + group: probes + description: GET /ping returns 200 with an empty body (no auth) + method: GET + path: /ping + auth: none + alsoMulticloud: true + expect: + status: [200] + bodyEmpty: true + + - id: liveness + group: probes + description: GET /livenessProbe returns 200 with an empty body + method: GET + path: /livenessProbe + auth: none + alsoMulticloud: true + expect: + status: [200] + bodyEmpty: true + + - id: readiness + group: probes + description: GET /readinessProbe returns 200 with an empty body + method: GET + path: /readinessProbe + auth: none + alsoMulticloud: true + expect: + status: [200] + bodyEmpty: true diff --git a/ACM-42568_go_migration/catalog/10-kube-proxy.yaml b/ACM-42568_go_migration/catalog/10-kube-proxy.yaml new file mode 100644 index 0000000000..3a9b00f852 --- /dev/null +++ b/ACM-42568_go_migration/catalog/10-kube-proxy.yaml @@ -0,0 +1,200 @@ +# Copyright Contributors to the Open Cluster Management project +cases: + - id: kube-api-versions + group: kube-proxy + description: GET /api passthrough to kube-apiserver with user token + method: GET + path: /api + auth: bearer + alsoMulticloud: true + headers: + Accept: application/json + expect: + status: [200] + contentTypeContains: json + headerPresent: [content-type] + jsonKeys: [kind, versions] + jsonPathEquals: + kind: APIVersions + + - id: kube-apis + group: kube-proxy + description: GET /apis discovery document + method: GET + path: /apis + auth: bearer + alsoMulticloud: true + soft: true + timeoutSeconds: 60 + headers: + Accept: application/json + expect: + status: [200] + contentTypeContains: json + jsonKeys: [kind, groups] + jsonPathEquals: + kind: APIGroupList + + - id: kube-version + group: kube-proxy + description: GET /version passthrough + method: GET + path: /version + auth: bearer + alsoMulticloud: true + headers: + Accept: application/json + expect: + status: [200] + contentTypeContains: json + jsonKeys: [major, minor, gitVersion] + + - id: kube-version-slash + group: kube-proxy + description: GET /version/ trailing slash + method: GET + path: /version/ + auth: bearer + expect: + status: [200] + contentTypeContains: json + jsonKeys: [major, minor, gitVersion] + + - id: kube-namespaces + group: kube-proxy + description: GET /api/v1/namespaces list + method: GET + path: /api/v1/namespaces + auth: bearer + soft: true + timeoutSeconds: 60 + headers: + Accept: application/json + expect: + status: [200] + contentTypeContains: json + jsonKeys: [kind, items] + jsonPathEquals: + kind: NamespaceList + + - id: kube-namespace-default + group: kube-proxy + description: GET namespaced core resource + method: GET + path: /api/v1/namespaces/default + auth: bearer + headers: + Accept: application/json + expect: + status: [200] + contentTypeContains: json + jsonPathEquals: + kind: Namespace + + - id: kube-ssar + group: kube-proxy + description: POST SelfSubjectAccessReview via /apis (frontend RBAC) + method: POST + path: /apis/authorization.k8s.io/v1/selfsubjectaccessreviews + auth: bearer + contentType: application/json + headers: + Accept: application/json + body: + apiVersion: authorization.k8s.io/v1 + kind: SelfSubjectAccessReview + spec: + resourceAttributes: + verb: list + resource: namespaces + expect: + status: [200, 201] + contentTypeContains: json + jsonKeys: [kind, spec, status] + jsonPathEquals: + kind: SelfSubjectAccessReview + + - id: kube-dry-run-create-cm + group: kube-proxy + description: POST ConfigMap with dryRun=All (no persist) + method: POST + path: /api/v1/namespaces/default/configmaps?dryRun=All + auth: bearer + contentType: application/json + headers: + Accept: application/json + body: + apiVersion: v1 + kind: ConfigMap + metadata: + name: acm-42590-contract-dryrun + namespace: default + data: + probe: "true" + expect: + status: [200, 201] + contentTypeContains: json + jsonPathEquals: + kind: ConfigMap + + - id: kube-merge-patch-dry-run + group: kube-proxy + description: PATCH merge-patch+json with dryRun=All + method: PATCH + path: /api/v1/namespaces/default?dryRun=All + auth: bearer + contentType: application/merge-patch+json + headers: + Accept: application/json + body: + metadata: + labels: + acm-42590-contract: "true" + expect: + status: [200] + contentTypeContains: json + jsonPathEquals: + kind: Namespace + + - id: kube-json-patch-dry-run + group: kube-proxy + description: PATCH json-patch+json with dryRun=All + method: PATCH + path: /api/v1/namespaces/default?dryRun=All + auth: bearer + contentType: application/json-patch+json + headers: + Accept: application/json + body: + - op: add + path: /metadata/labels/acm-42590-json-patch + value: "true" + expect: + status: [200, 422] + contentTypeContains: json + + - id: kube-label-selector + group: kube-proxy + description: GET with labelSelector query (frontend listResources) + method: GET + path: /api/v1/namespaces?labelSelector=kubernetes.io/metadata.name=default + auth: bearer + headers: + Accept: application/json + expect: + status: [200] + jsonPathEquals: + kind: NamespaceList + + - id: kube-proxy-header-allowlist + group: kube-proxy + description: Successful proxy responses include content-type from kube; cookie is not required on the response + method: GET + path: /api + auth: bearer + headers: + Accept: application/json + expect: + status: [200] + headerPresent: [content-type] + headerAbsent: [set-cookie] diff --git a/ACM-42568_go_migration/catalog/20-metrics-mcp-vm-serve.yaml b/ACM-42568_go_migration/catalog/20-metrics-mcp-vm-serve.yaml new file mode 100644 index 0000000000..8c0fea8157 --- /dev/null +++ b/ACM-42568_go_migration/catalog/20-metrics-mcp-vm-serve.yaml @@ -0,0 +1,116 @@ +# Copyright Contributors to the Open Cluster Management project +cases: + - id: metrics-prometheus-query + group: metrics + description: GET /prometheus/query rewritten to Prometheus /api/v1/query + method: GET + path: /prometheus/query?query=up + auth: bearer + alsoMulticloud: true + soft: true + softStatuses: [401, 403, 404, 500, 502, 503, 504] + expect: + status: [200] + contentTypeContains: json + + - id: metrics-observability-query + group: metrics + description: GET /observability/query rewritten to Observability /api/v1/query + method: GET + path: /observability/query?query=up + auth: bearer + alsoMulticloud: true + soft: true + softStatuses: [401, 403, 404, 500, 502, 503, 504] + expect: + status: [200] + contentTypeContains: json + + - id: metrics-prometheus-query-range + group: metrics + description: GET /prometheus/query_range as used by useMetricsPoll + method: GET + path: /prometheus/query_range?query=up&start=0&end=1&step=15 + auth: bearer + soft: true + softStatuses: [401, 403, 404, 500, 502, 503, 504] + expect: + status: [200] + + - id: mcp-hub-namespaces + group: managedclusterproxy + description: HTTP passthrough to hub via cluster-proxy-addon + method: GET + path: /managedclusterproxy/local-cluster/api/v1/namespaces + auth: bearer + alsoMulticloud: true + soft: true + softStatuses: [401, 403, 404, 500, 502, 503, 504] + headers: + Accept: application/json + expect: + status: [200] + contentTypeContains: json + + - id: vm-get-missing + group: vm + description: GET /virtualmachines/get for a missing VM returns JSON (kube Status or null) + method: GET + path: /virtualmachines/get/local-cluster/acm-42590-missing/default + auth: bearer + alsoMulticloud: true + soft: true + softStatuses: [401, 403, 404, 500, 502, 503] + expect: + status: [200] + contentTypeContains: json + + - id: vm-get-snapshot-missing + group: vm + description: GET /virtualmachinesnapshots/get for a missing snapshot + method: GET + path: /virtualmachinesnapshots/get/local-cluster/acm-42590-missing/default + auth: bearer + soft: true + softStatuses: [401, 403, 404, 500, 502, 503] + expect: + status: [200] + contentTypeContains: json + + - id: vm-resource-usage + group: vm + description: GET vmResourceUsage for default namespace on hub + method: GET + path: /vmResourceUsage/cluster/local-cluster/namespace/default + auth: bearer + alsoMulticloud: true + soft: true + softStatuses: [401, 403, 404, 500, 502, 503] + expect: + status: [200] + contentTypeContains: json + + - id: serve-missing-asset + group: serve + description: Catch-all static handler returns 404 for unknown files + method: GET + path: /acm-42590-definitely-missing.json + auth: none + expect: + status: [404] + bodyEmpty: true + + - id: serve-plugin-manifest + group: serve + description: plugin-manifest.json when public assets exist (404 in typical local backend) + method: GET + path: /plugin/plugin-manifest.json + auth: none + alsoMulticloud: true + soft: true + softStatuses: [404] + expect: + status: [200] + headerEquals: + cache-control: no-cache + contentTypeContains: json diff --git a/ACM-42568_go_migration/catalog/30-auth-cluster.yaml b/ACM-42568_go_migration/catalog/30-auth-cluster.yaml new file mode 100644 index 0000000000..ec913bd739 --- /dev/null +++ b/ACM-42568_go_migration/catalog/30-auth-cluster.yaml @@ -0,0 +1,185 @@ +# Copyright Contributors to the Open Cluster Management project +cases: + - id: authenticated + group: auth + description: GET /authenticated probes kube GET /api with the user token + method: GET + path: /authenticated + auth: bearer + alsoMulticloud: true + headers: + Accept: application/json + expect: + status: [200] + bodyEmpty: true + + - id: authenticated-cookie + group: auth + description: GET /authenticated with acm-access-token-cookie + method: GET + path: /authenticated + auth: cookie + headers: + Accept: application/json + expect: + status: [200] + bodyEmpty: true + + - id: username + group: auth + description: GET /username TokenReview wrapper + method: GET + path: /username + auth: bearer + alsoMulticloud: true + expect: + status: [200] + contentTypeContains: json + jsonKeys: [statusCode, body] + + - id: userpreference-get + group: auth + description: GET /userpreference UserPreference CR (may be null JSON) + method: GET + path: /userpreference + auth: bearer + alsoMulticloud: true + expect: + status: [200] + contentTypeContains: json + + - id: configure + group: auth + description: GET /configure OAuth discovery (dev-only when NODE_ENV is not production) + method: GET + path: /configure + auth: none + alsoMulticloud: true + expect: + status: [200] + contentTypeContains: json + jsonKeys: [token_endpoint] + + - id: login-redirect + group: auth + description: GET /login 302 to OAuth authorization_endpoint + method: GET + path: /login + auth: none + alsoMulticloud: true + expect: + status: [302] + headerPresent: [location] + + - id: hub + group: cluster-info + description: GET /hub global-hub and authentication flags + method: GET + path: /hub + auth: bearer + alsoMulticloud: true + expect: + status: [200] + contentTypeContains: json + jsonKeys: [isGlobalHub, localHubName, isHubSelfManaged, isObservabilityInstalled, authentication] + + - id: cluster-version + group: cluster-info + description: GET /cluster-version desired OCP version + method: GET + path: /cluster-version + auth: bearer + alsoMulticloud: true + expect: + status: [200] + contentTypeContains: json + + - id: hypershift-status + group: cluster-info + description: GET /hypershift-status?hubName=local-cluster + method: GET + path: /hypershift-status?hubName=local-cluster + auth: bearer + alsoMulticloud: true + expect: + status: [200] + contentTypeContains: json + jsonKeys: [statusCode, body] + + - id: mch-components + group: cluster-info + description: GET /multiclusterhub/components + method: GET + path: /multiclusterhub/components + auth: bearer + alsoMulticloud: true + expect: + status: [200] + contentTypeContains: json + + - id: mce-components + group: cluster-info + description: GET /multiclusterengine/components + method: GET + path: /multiclusterengine/components + auth: bearer + alsoMulticloud: true + expect: + status: [200] + contentTypeContains: json + + - id: api-paths + group: cluster-info + description: GET /apiPaths discovery map used by the frontend + method: GET + path: /apiPaths + auth: bearer + alsoMulticloud: true + timeoutSeconds: 120 + expect: + status: [200] + contentTypeContains: json + jsonType: object + + - id: operator-check-acm + group: cluster-info + description: POST /operatorCheck for ACM operator + method: POST + path: /operatorCheck + auth: bearer + alsoMulticloud: true + contentType: application/json + body: + operator: advanced-cluster-management + expect: + status: [200] + contentTypeContains: json + jsonKeys: [operator, installed] + jsonPathEquals: + operator: advanced-cluster-management + + - id: operator-check-kubevirt + group: cluster-info + description: POST /operatorCheck for kubevirt + method: POST + path: /operatorCheck + auth: bearer + contentType: application/json + body: + operator: kubevirt-hyperconverged + expect: + status: [200] + jsonKeys: [operator, installed] + + - id: operator-check-bad-body + group: cluster-info + description: POST /operatorCheck with unknown operator is 400 + method: POST + path: /operatorCheck + auth: bearer + contentType: application/json + body: + operator: not-a-real-operator + expect: + status: [400] + bodyEmpty: true diff --git a/ACM-42568_go_migration/catalog/40-aggregate-search.yaml b/ACM-42568_go_migration/catalog/40-aggregate-search.yaml new file mode 100644 index 0000000000..979c2dfb25 --- /dev/null +++ b/ACM-42568_go_migration/catalog/40-aggregate-search.yaml @@ -0,0 +1,170 @@ +# Copyright Contributors to the Open Cluster Management project +# +# POST /aggregate/{applications,statuses,appSetData} — Go when CONSOLE_INFORMER_CACHE is on (ACM-42600); +# Node sidecar until that migration lands on your branch. +# POST /proxy/search and the Search WebSocket stay on Node (ACM-42601). + +cases: + - id: aggregate-applications-page + group: aggregate + description: POST /aggregate/applications paginated list + method: POST + path: /aggregate/applications + auth: bearer + alsoMulticloud: true + contentType: application/json + timeoutSeconds: 60 + body: + page: 1 + perPage: 10 + sortBy: + index: 0 + direction: asc + expect: + status: [200] + contentTypeContains: json + jsonKeys: + - page + - items + - processedItemCount + - emptyResult + - isPreProcessed + - request + + - id: aggregate-applications-filter-search + group: aggregate + description: POST /aggregate/applications with filter, search, and sort + method: POST + path: /aggregate/applications + auth: bearer + alsoMulticloud: true + contentType: application/json + timeoutSeconds: 60 + body: + page: 1 + perPage: 10 + search: tes + filters: + type: [subscription] + sortBy: + index: 0 + direction: desc + expect: + status: [200] + contentTypeContains: json + + - id: aggregate-applications-per-page-all + group: aggregate + description: POST /aggregate/applications with perPage -1 (export path) + method: POST + path: /aggregate/applications + auth: bearer + alsoMulticloud: true + contentType: application/json + timeoutSeconds: 60 + body: + page: 1 + perPage: -1 + expect: + status: [200] + contentTypeContains: json + + - id: aggregate-statuses + group: aggregate + description: POST /aggregate/statuses filter counts (itemCount is a JSON string) + method: POST + path: /aggregate/statuses + auth: bearer + alsoMulticloud: true + contentType: application/json + timeoutSeconds: 60 + body: + clusters: [] + expect: + status: [200] + contentTypeContains: json + jsonKeys: + - itemCount + - filterCounts + - systemAppNSPrefixes + - loading + + - id: aggregate-appset-data + group: aggregate + description: POST /aggregate/appSetData (200 when ApplicationSet exists; 400 otherwise) + method: POST + path: /aggregate/appSetData + auth: bearer + alsoMulticloud: true + contentType: application/json + timeoutSeconds: 60 + soft: true + softStatuses: [400] + body: + apiVersion: argoproj.io/v1alpha1 + kind: ApplicationSet + metadata: + name: acm-contract-appset + namespace: openshift-gitops + expect: + status: [200] + contentTypeContains: json + jsonKeys: + - appset + - clusterList + - appSetApps + - appStatusByNameMap + - isAppSetPullModel + + - id: aggregate-appset-invalid-json + group: aggregate + description: POST /aggregate/appSetData with invalid JSON is 400 + method: POST + path: /aggregate/appSetData + auth: bearer + alsoMulticloud: true + contentType: application/json + rawBody: "{" + expect: + status: [400] + contentTypeContains: json + jsonKeys: [error] + + - id: aggregate-unknown + group: aggregate + description: POST /aggregate/not-a-type is 404 with empty body + method: POST + path: /aggregate/not-a-type + auth: bearer + alsoMulticloud: true + contentType: application/json + body: {} + expect: + status: [404] + bodyEmpty: true + + - id: search-graphql + group: search + description: POST /proxy/search GraphQL search query (Go searchproxy) + method: POST + path: /proxy/search + auth: bearer + alsoMulticloud: true + contentType: application/json + timeoutSeconds: 20 + soft: true + softStatuses: [401, 403, 404, 500, 502, 503, 504] + body: + operationName: searchResult + variables: + input: + - filters: + - property: kind + values: [Pod] + - property: name + values: [search-api*] + limit: 1 + query: "query searchResult($input: [SearchInput]) {\n searchResult: search(input: $input) {\n items\n }\n}" + expect: + status: [200, 201] + contentTypeContains: json diff --git a/ACM-42568_go_migration/catalog/50-sse.yaml b/ACM-42568_go_migration/catalog/50-sse.yaml new file mode 100644 index 0000000000..5332d5c1b1 --- /dev/null +++ b/ACM-42568_go_migration/catalog/50-sse.yaml @@ -0,0 +1,54 @@ +# Copyright Contributors to the Open Cluster Management project +cases: + - id: events-sse + group: sse + kind: sse + description: GET /events SSE snapshot START/SETTINGS/EOP/LOADED with RBAC-filtered resources + method: GET + path: /events + auth: both + alsoMulticloud: true + timeoutSeconds: 120 + headers: + Accept: text/event-stream + Accept-Encoding: gzip + expect: + status: [200] + contentTypeContains: text/event-stream + headerEquals: + cache-control: no-store, no-transform + setCookieContains: [watch=] + sse: + firstType: START + lastType: LOADED + requireTypes: [START, SETTINGS, EOP, LOADED] + objectHasKindApiVersionName: true + kindsSubsetOfWatched: true + settingsKeys: + - LOG_LEVEL + - ansibleIntegration + - singleNodeOpenshift + - globalSearchFeatureFlag + - id: events-rbac-sse + group: sse + kind: sse + description: GET /events/rbac SSE ClusterRole snapshot (Go-owned; Node never served this path) + method: GET + path: /events/rbac + auth: both + alsoMulticloud: true + soft: true + softStatuses: [404] + timeoutSeconds: 30 + headers: + Accept: text/event-stream + expect: + status: [200] + contentTypeContains: text/event-stream + headerEquals: + cache-control: no-store, no-transform + sse: + firstType: START + lastType: LOADED + requireTypes: [START, EOP, LOADED] + objectHasKindApiVersionName: true diff --git a/ACM-42568_go_migration/catalog/60-websocket.yaml b/ACM-42568_go_migration/catalog/60-websocket.yaml new file mode 100644 index 0000000000..f47c87a282 --- /dev/null +++ b/ACM-42568_go_migration/catalog/60-websocket.yaml @@ -0,0 +1,32 @@ +# Copyright Contributors to the Open Cluster Management project +cases: + - id: search-websocket + group: websocket + kind: websocket + description: WebSocket upgrade /multicloud/proxy/search (Go searchproxy graphql-ws relay) + method: GET + path: /multicloud/proxy/search + auth: bearer + timeoutSeconds: 20 + soft: true + softStatuses: [401, 403, 404, 500, 502, 503, 504] + ws: + subprotocol: graphql-transport-ws + send: + - '{"type":"connection_init","payload":{}}' + expectType: connection_ack + + - id: mcp-websocket-watch + group: websocket + kind: websocket + description: WebSocket upgrade /multicloud/managedclusterproxy kube watch + method: GET + path: /multicloud/managedclusterproxy/local-cluster/api/v1/namespaces?watch=true + auth: bearer + timeoutSeconds: 15 + soft: true + softStatuses: [401, 403, 404, 500, 502, 503, 504] + ws: + subprotocol: "" + send: [] + expectUpgrade: true diff --git a/ACM-42568_go_migration/catalog/70-long-tail.yaml b/ACM-42568_go_migration/catalog/70-long-tail.yaml new file mode 100644 index 0000000000..d63ccb5106 --- /dev/null +++ b/ACM-42568_go_migration/catalog/70-long-tail.yaml @@ -0,0 +1,233 @@ +# Long-tail HTTP (served by Go: backend/internal/{ansibletower,placementdebug,upgraderisks,rosa}). +# Keep cases soft when Insights/OCM/placement upstreams are absent. Do not hard-fail without those services. +cases: + - id: ansibletower-bad-json + group: long-tail + description: POST /ansibletower invalid JSON is 400 + method: POST + path: /ansibletower + auth: bearer + alsoMulticloud: true + contentType: application/json + rawBody: "not-json" + expect: + status: [400] + bodyEmpty: true + + - id: ansibletower-missing-fields + group: long-tail + description: POST /ansibletower missing allow-listed fields is 400 + method: POST + path: /ansibletower + auth: bearer + contentType: application/json + body: + secretName: x + expect: + status: [400] + bodyEmpty: true + + - id: placement-debug + group: long-tail + description: POST /placement-debug (503 if OCM CA missing; otherwise upstream JSON) + method: POST + path: /placement-debug + auth: bearer + alsoMulticloud: true + contentType: application/json + soft: true + softStatuses: [400, 404, 500, 502, 503, 504] + body: + apiVersion: cluster.open-cluster-management.io/v1beta1 + kind: Placement + metadata: + name: acm-42590-contract + namespace: default + spec: + clusterSets: + - global + expect: + status: [200] + contentTypeContains: json + + - id: upgrade-risks + group: long-tail + description: POST /upgrade-risks-prediction with empty cluster id list + method: POST + path: /upgrade-risks-prediction + auth: bearer + alsoMulticloud: true + contentType: application/json + soft: true + softStatuses: [400, 401, 403, 404, 500, 502, 503, 504] + body: + clusterIds: [] + expect: + status: [200] + contentTypeContains: json + + - id: rosa-aws-account-ids + group: long-tail + description: POST /aws-account-ids ROSA wizard (needs OCM SA; soft) + method: POST + path: /aws-account-ids + auth: bearer + alsoMulticloud: true + contentType: application/json + soft: true + softStatuses: [400, 401, 403, 404, 500, 502, 503] + body: + service_account_id: "invalid" + service_account_secret: "invalid" + expect: + status: [200] + + - id: rosa-billing-accounts + group: long-tail + method: POST + path: /aws-billing-accounts + auth: bearer + contentType: application/json + soft: true + softStatuses: [400, 401, 403, 404, 500, 502, 503] + body: + service_account_id: "invalid" + service_account_secret: "invalid" + expect: + status: [200] + + - id: rosa-oidc-configs + group: long-tail + method: POST + path: /oidc-configs + auth: bearer + contentType: application/json + soft: true + softStatuses: [400, 401, 403, 404, 500, 502, 503] + body: + service_account_id: "invalid" + service_account_secret: "invalid" + expect: + status: [200] + + - id: rosa-regions + group: long-tail + method: POST + path: /regions + auth: bearer + contentType: application/json + soft: true + softStatuses: [400, 401, 403, 404, 500, 502, 503] + body: + service_account_id: "invalid" + service_account_secret: "invalid" + expect: + status: [200] + + - id: rosa-cluster-name-check + group: long-tail + method: POST + path: /cluster-name-check + auth: bearer + contentType: application/json + soft: true + softStatuses: [400, 401, 403, 404, 500, 502, 503] + body: + service_account_id: "invalid" + service_account_secret: "invalid" + cluster_name: acm-42590-contract + expect: + status: [200] + + - id: rosa-sts-role-arns + group: long-tail + method: POST + path: /sts-role-arns + auth: bearer + contentType: application/json + soft: true + softStatuses: [400, 401, 403, 404, 500, 502, 503] + body: + service_account_id: "invalid" + service_account_secret: "invalid" + expect: + status: [200] + + - id: rosa-vpcs + group: long-tail + method: POST + path: /vpcs + auth: bearer + contentType: application/json + soft: true + softStatuses: [400, 401, 403, 404, 500, 502, 503] + body: + service_account_id: "invalid" + service_account_secret: "invalid" + aws: + account_id: "000000000000" + sts: + role_arn: "arn:aws:iam::000000000000:role/x" + region: + id: us-east-1 + expect: + status: [200] + + - id: rosa-sts-ocm-role + group: long-tail + method: POST + path: /sts-ocm-role + auth: bearer + contentType: application/json + soft: true + softStatuses: [400, 401, 403, 404, 500, 502, 503] + body: + service_account_id: "invalid" + service_account_secret: "invalid" + expect: + status: [200] + + - id: rosa-sts-user-role + group: long-tail + method: POST + path: /sts-user-role + auth: bearer + contentType: application/json + soft: true + softStatuses: [400, 401, 403, 404, 500, 502, 503] + body: + service_account_id: "invalid" + service_account_secret: "invalid" + expect: + status: [200] + + - id: rosa-openshift-versions + group: long-tail + method: POST + path: /openshift-versions + auth: bearer + contentType: application/json + soft: true + softStatuses: [400, 401, 403, 404, 500, 502, 503] + body: + service_account_id: "invalid" + service_account_secret: "invalid" + expect: + status: [200] + + - id: rosa-machine-types + group: long-tail + method: POST + path: /machine-types + auth: bearer + contentType: application/json + soft: true + softStatuses: [400, 401, 403, 404, 500, 502, 503] + body: + service_account_id: "invalid" + service_account_secret: "invalid" + region: us-east-1 + role_arn: "arn:aws:iam::000000000000:role/x" + availability_zones: ["us-east-1a"] + expect: + status: [200] diff --git a/ACM-42568_go_migration/catalog/80-negative.yaml b/ACM-42568_go_migration/catalog/80-negative.yaml new file mode 100644 index 0000000000..004b897565 --- /dev/null +++ b/ACM-42568_go_migration/catalog/80-negative.yaml @@ -0,0 +1,132 @@ +# Copyright Contributors to the Open Cluster Management project +# Use auth: invalid (fake Bearer), not auth: none. In NODE_ENV=development getToken() +# falls back to certs localStorage admin-token when the request has no cookie/Authorization. +cases: + - id: unauth-api + group: negative + description: GET /api with an invalid Bearer is 401 kube Status (proxy forwards the token) + method: GET + path: /api + auth: invalid + alsoMulticloud: true + expect: + status: [401] + contentTypeContains: json + jsonPathEquals: + kind: Status + + - id: unauth-apis + group: negative + method: GET + path: /apis + auth: invalid + expect: + status: [401] + contentTypeContains: json + jsonPathEquals: + kind: Status + + - id: unauth-events + group: negative + method: GET + path: /events + auth: invalid + expect: + status: [401] + bodyEmpty: true + + - id: unauth-authenticated + group: negative + method: GET + path: /authenticated + auth: invalid + expect: + status: [401] + bodyEmpty: true + + - id: unauth-username + group: negative + method: GET + path: /username + auth: invalid + expect: + status: [401] + bodyEmpty: true + + - id: unauth-hub + group: negative + method: GET + path: /hub + auth: invalid + expect: + status: [401] + bodyEmpty: true + + - id: unauth-search + group: negative + method: POST + path: /proxy/search + auth: invalid + contentType: application/json + body: {} + expect: + status: [401] + bodyEmpty: true + + - id: unauth-aggregate + group: negative + method: POST + path: /aggregate/statuses + auth: invalid + contentType: application/json + body: + clusters: [] + expect: + status: [401] + bodyEmpty: true + + - id: unauth-mcp + group: negative + method: GET + path: /managedclusterproxy/local-cluster/api/v1/namespaces + auth: invalid + expect: + status: [401] + bodyEmpty: true + + - id: unauth-operator-check + group: negative + method: POST + path: /operatorCheck + auth: invalid + contentType: application/json + body: + operator: advanced-cluster-management + expect: + status: [401] + bodyEmpty: true + + - id: method-not-found-post-ping + group: negative + description: POST /ping is not registered (404 from find-my-way) + method: POST + path: /ping + auth: none + expect: + status: [404] + bodyEmpty: true + + - id: cors-options-dev + group: negative + description: OPTIONS preflight in development returns 200 with CORS headers + method: OPTIONS + path: /api + auth: none + alsoMulticloud: true + headers: + Origin: https://localhost:3000 + Access-Control-Request-Method: GET + Access-Control-Request-Headers: authorization,content-type + expect: + status: [200] + headerPresent: [access-control-allow-origin, access-control-allow-credentials] diff --git a/ACM-42568_go_migration/catalog/watched-resources.yaml b/ACM-42568_go_migration/catalog/watched-resources.yaml new file mode 100644 index 0000000000..010211712d --- /dev/null +++ b/ACM-42568_go_migration/catalog/watched-resources.yaml @@ -0,0 +1,173 @@ +# Copyright Contributors to the Open Cluster Management project +# Watch specs from backend-node/src/routes/events.ts `definitions`. +# Selectors, polled, and forwardEventsToClients must stay 1:1 with that array. +# ClusterRole is Go-only (GET /events/rbac); it is not in events.ts. +resources: + - kind: ClusterManagementAddOn + apiVersion: addon.open-cluster-management.io/v1alpha1 + - kind: ManagedClusterAddOn + apiVersion: addon.open-cluster-management.io/v1alpha1 + - kind: Agent + apiVersion: agent-install.openshift.io/v1beta1 + - kind: AgentServiceConfig + apiVersion: agent-install.openshift.io/v1beta1 + - kind: InfraEnv + apiVersion: agent-install.openshift.io/v1beta1 + - kind: NMStateConfig + apiVersion: agent-install.openshift.io/v1beta1 + - kind: Application + apiVersion: app.k8s.io/v1beta1 + - kind: Channel + apiVersion: apps.open-cluster-management.io/v1 + - kind: GitOpsCluster + apiVersion: apps.open-cluster-management.io/v1beta1 + - kind: HelmRelease + apiVersion: apps.open-cluster-management.io/v1 + - kind: Subscription + apiVersion: apps.open-cluster-management.io/v1 + - kind: SubscriptionReport + apiVersion: apps.open-cluster-management.io/v1alpha1 + - kind: Application + apiVersion: argoproj.io/v1alpha1 + polled: true + - kind: ApplicationSet + apiVersion: argoproj.io/v1alpha1 + polled: true + - kind: ArgoCD + apiVersion: argoproj.io/v1alpha1 + - kind: Authentication + apiVersion: config.openshift.io/v1 + forwardEventsToClients: false + - kind: Infrastructure + apiVersion: config.openshift.io/v1 + - kind: CertificateSigningRequest + apiVersion: certificates.k8s.io/v1 + labelSelector: + open-cluster-management.io/cluster-name: "" + - kind: ManagedCluster + apiVersion: cluster.open-cluster-management.io/v1 + - kind: Placement + apiVersion: cluster.open-cluster-management.io/v1beta1 + - kind: PlacementDecision + apiVersion: cluster.open-cluster-management.io/v1beta1 + - kind: ManagedClusterSetBinding + apiVersion: cluster.open-cluster-management.io/v1beta2 + - kind: ManagedClusterSet + apiVersion: cluster.open-cluster-management.io/v1beta2 + - kind: ClusterCurator + apiVersion: cluster.open-cluster-management.io/v1beta1 + - kind: Subscription + apiVersion: operators.coreos.com/v1alpha1 + - kind: ClusterExtension + apiVersion: olm.operatorframework.io/v1 + - kind: DiscoveredCluster + apiVersion: discovery.open-cluster-management.io/v1 + - kind: DiscoveryConfig + apiVersion: discovery.open-cluster-management.io/v1 + - kind: AgentClusterInstall + apiVersion: extensions.hive.openshift.io/v1beta1 + - kind: ClusterClaim + apiVersion: hive.openshift.io/v1 + - kind: ClusterDeployment + apiVersion: hive.openshift.io/v1 + - kind: ClusterImageSet + apiVersion: hive.openshift.io/v1 + - kind: ClusterPool + apiVersion: hive.openshift.io/v1 + - kind: ClusterProvision + apiVersion: hive.openshift.io/v1 + - kind: MachinePool + apiVersion: hive.openshift.io/v1 + - kind: ManagedClusterInfo + apiVersion: internal.open-cluster-management.io/v1beta1 + - kind: BareMetalHost + apiVersion: metal3.io/v1alpha1 + - kind: MultiClusterEngine + apiVersion: multicluster.openshift.io/v1 + - kind: ClusterVersion + apiVersion: config.openshift.io/v1 + - kind: StorageClass + apiVersion: storage.k8s.io/v1 + - kind: PlacementBinding + apiVersion: policy.open-cluster-management.io/v1 + - kind: Policy + apiVersion: policy.open-cluster-management.io/v1 + - kind: PolicyAutomation + apiVersion: policy.open-cluster-management.io/v1beta1 + - kind: PolicySet + apiVersion: policy.open-cluster-management.io/v1beta1 + - kind: SubmarinerConfig + apiVersion: submarineraddon.open-cluster-management.io/v1alpha1 + - kind: AnsibleJob + apiVersion: tower.ansible.com/v1alpha1 + - kind: AnsibleWorkflow + apiVersion: tower.ansible.com/v1alpha1 + - kind: ConfigMap + apiVersion: v1 + fieldSelector: + metadata.name: assisted-service + - kind: ConfigMap + apiVersion: v1 + fieldSelector: + metadata.namespace: openshift-config-managed + metadata.name: console-public + - kind: ConfigMap + apiVersion: v1 + fieldSelector: + metadata.name: console-search-config + - kind: Namespace + apiVersion: v1 + - kind: Secret + apiVersion: v1 + labelSelector: + cluster.open-cluster-management.io/credentials: "" + - kind: Secret + apiVersion: v1 + labelSelector: + cluster.open-cluster-management.io/type: ans + - kind: Secret + apiVersion: v1 + fieldSelector: + metadata.name: auto-import-secret + - kind: Secret + apiVersion: v1 + labelSelector: + argocd.argoproj.io/secret-type: repository + - kind: PolicyReport + apiVersion: wgpolicyk8s.io/v1alpha2 + - kind: HostedCluster + apiVersion: hypershift.openshift.io/v1beta1 + - kind: NodePool + apiVersion: hypershift.openshift.io/v1beta1 + - kind: AgentMachine + apiVersion: capi-provider.agent-install.openshift.io/v1alpha1 + - kind: ConfigMap + apiVersion: v1 + labelSelector: + hypershift.openshift.io/supported-versions: "true" + - kind: Search + apiVersion: search.open-cluster-management.io/v1alpha1 + - kind: ConfigMap + apiVersion: v1 + fieldSelector: + metadata.name: grafana-dashboard-acm-openshift-virtualization-clusters-overview + - kind: ConfigMap + apiVersion: v1 + fieldSelector: + metadata.name: grafana-dashboard-acm-openshift-virtualization-single-vm-view + - kind: MulticlusterRoleAssignment + apiVersion: rbac.open-cluster-management.io/v1beta1 + - kind: User + apiVersion: user.openshift.io/v1 + - kind: Group + apiVersion: user.openshift.io/v1 + - kind: Service + apiVersion: v1 + fieldSelector: + metadata.name: cluster-proxy-addon-user + metadata.namespace: multicluster-engine + - kind: ClusterRole + apiVersion: rbac.authorization.k8s.io/v1 + source: events-rbac + labelSelector: + rbac.open-cluster-management.io/filter: vm-clusterroles diff --git a/ACM-42568_go_migration/client.go b/ACM-42568_go_migration/client.go new file mode 100644 index 0000000000..0cfe3f579a --- /dev/null +++ b/ACM-42568_go_migration/client.go @@ -0,0 +1,134 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +package contract + +import ( + "bytes" + "compress/flate" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type Capture struct { + Status int + Headers http.Header + Body []byte + Decoded []byte // after content-encoding +} + +func (c Capture) Header(name string) string { + return strings.Join(c.Headers.Values(name), ", ") +} + +func (c Capture) HeaderMap() map[string][]string { + out := map[string][]string{} + for k, v := range c.Headers { + out[strings.ToLower(k)] = append([]string{}, v...) + } + return out +} + +func (cfg Config) Do(client *http.Client, base string, cs Case, path string) (Capture, error) { + url := cfg.ResolveURL(base, path) + var body io.Reader + if cs.RawBody != "" { + body = strings.NewReader(cs.RawBody) + } else if cs.Body != nil { + b, err := json.Marshal(cs.Body) + if err != nil { + return Capture{}, err + } + body = bytes.NewReader(b) + } + req, err := http.NewRequest(cs.Method, url, body) + if err != nil { + return Capture{}, err + } + for k, v := range cs.Headers { + req.Header.Set(k, v) + } + if cs.ContentType != "" { + req.Header.Set("Content-Type", cs.ContentType) + } else if cs.Body != nil && req.Header.Get("Content-Type") == "" { + req.Header.Set("Content-Type", "application/json") + } + cfg.applyAuth(req, cs.Auth) + + resp, err := client.Do(req) + if err != nil { + return Capture{}, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err != nil { + return Capture{}, err + } + decoded, err := decodeBody(resp.Header.Get("Content-Encoding"), raw) + if err != nil { + decoded = raw + } + return Capture{Status: resp.StatusCode, Headers: resp.Header.Clone(), Body: raw, Decoded: decoded}, nil +} + +func (cfg Config) applyAuth(req *http.Request, auth string) { + if cfg.Token == "" { + return + } + switch strings.ToLower(auth) { + case "bearer": + req.Header.Set("Authorization", "Bearer "+cfg.Token) + case "cookie": + req.AddCookie(&http.Cookie{Name: "acm-access-token-cookie", Value: cfg.Token}) + case "both": + req.Header.Set("Authorization", "Bearer "+cfg.Token) + req.AddCookie(&http.Cookie{Name: "acm-access-token-cookie", Value: cfg.Token}) + case "invalid": + // Must send a syntactically present token. In NODE_ENV=development, getToken() + // falls back to certs localStorage admin-token when Authorization and cookie are absent. + req.Header.Set("Authorization", "Bearer acm-42590-invalid-token") + } +} + +func decodeBody(encoding string, body []byte) ([]byte, error) { + encoding = strings.ToLower(strings.TrimSpace(encoding)) + switch encoding { + case "", "identity": + return body, nil + case "gzip": + r, err := gzip.NewReader(bytes.NewReader(body)) + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) + case "deflate": + r := flate.NewReader(bytes.NewReader(body)) + defer r.Close() + return io.ReadAll(r) + default: + return body, fmt.Errorf("unsupported content-encoding %s", encoding) + } +} + +func ProbeBackend(cfg Config) error { + client := cfg.NewHTTPClient(8 * time.Second) + req, err := http.NewRequest(http.MethodGet, cfg.ResolveURL(cfg.BackendURL, "/ping"), nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + io.Copy(io.Discard, resp.Body) //nolint:errcheck + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("GET /ping -> %d", resp.StatusCode) + } + return nil +} diff --git a/ACM-42568_go_migration/compare-informer-cache.sh b/ACM-42568_go_migration/compare-informer-cache.sh new file mode 100755 index 0000000000..ee4402382e --- /dev/null +++ b/ACM-42568_go_migration/compare-informer-cache.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Copyright Contributors to the Open Cluster Management project +# +# ACM-42597 snapshot gate: compare Go informer cache vs Node (or a file dump). +# On ACM-42597+, GET /debug/informer-snapshot is served in non-production. +# Skips when the endpoint is 404 (older backends). +# +# Usage (npm run plugins already up): +# ./compare-informer-cache.sh +# +# Optional: +# CONTRACT_GO_SNAPSHOT_URL default https://localhost:4000/debug/informer-snapshot +# CONTRACT_NODE_SNAPSHOT_URL Node dump URL (if instrumented) +# CONTRACT_GO_SNAPSHOT_FILE JSON file { "items": [ {apiVersion,kind,namespace,name} ] } +# CONTRACT_NODE_SNAPSHOT_FILE same shape for Node / kubectl dump +set -euo pipefail + +cd "$(dirname "$0")" + +export CONTRACT_BACKEND_URL="${CONTRACT_BACKEND_URL:-https://localhost:4000}" +if [[ -z "${CONTRACT_TOKEN:-}" ]]; then + CONTRACT_TOKEN="$(oc whoami -t 2>/dev/null || true)" + export CONTRACT_TOKEN +fi +export CONTRACT_GO_SNAPSHOT_URL="${CONTRACT_GO_SNAPSHOT_URL:-${CONTRACT_BACKEND_URL%/}/debug/informer-snapshot}" + +echo "Go snapshot: ${CONTRACT_GO_SNAPSHOT_URL}" +if [[ -n "${CONTRACT_NODE_SNAPSHOT_URL:-}" ]]; then + echo "Node snapshot URL: ${CONTRACT_NODE_SNAPSHOT_URL}" +fi +if [[ -n "${CONTRACT_NODE_SNAPSHOT_FILE:-}" ]]; then + echo "Node snapshot file: ${CONTRACT_NODE_SNAPSHOT_FILE}" +fi + +go test . -count=1 -timeout 2m -v -run 'TestCacheSnapshotSkipWhenMissing|TestWatchedResourcesMatchEventsTS' diff --git a/ACM-42568_go_migration/compare.go b/ACM-42568_go_migration/compare.go new file mode 100644 index 0000000000..1480dd9204 --- /dev/null +++ b/ACM-42568_go_migration/compare.go @@ -0,0 +1,82 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +package contract + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "sort" +) + +type Recorded struct { + ID string `json:"id"` + Path string `json:"path"` + Status int `json:"status"` + Headers map[string][]string `json:"headers"` + Body any `json:"body"` +} + +func DiffCaptures(a, b Capture) error { + if a.Status != b.Status { + return fmt.Errorf("status %d vs %d", a.Status, b.Status) + } + ha := NormalizeHeaders(a.HeaderMap()) + hb := NormalizeHeaders(b.HeaderMap()) + // Only compare headers that both sides set among a small allowlist plus expected ones. + interesting := []string{ + "content-type", "content-encoding", "cache-control", + "x-frame-options", "content-security-policy", "location", + } + for _, name := range interesting { + va, vb := FirstHeader(ha, name), FirstHeader(hb, name) + if va != vb && va != "" && vb != "" { + return fmt.Errorf("header %s %q vs %q", name, va, vb) + } + } + na := NormalizeBody(a.Decoded) + nb := NormalizeBody(b.Decoded) + if !reflect.DeepEqual(na, nb) { + return fmt.Errorf("body mismatch\nA=%s\nB=%s", PrettyJSON(na), PrettyJSON(nb)) + } + return nil +} + +func WriteRecord(dir string, rec Recorded) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + b, err := json.MarshalIndent(rec, "", " ") + if err != nil { + return err + } + name := rec.ID + if rec.Path != "" { + name = rec.ID + "_" + sanitizeFile(rec.Path) + } + return os.WriteFile(filepath.Join(dir, name+".json"), b, 0o644) +} + +func sanitizeFile(s string) string { + out := make([]rune, 0, len(s)) + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + out = append(out, r) + default: + out = append(out, '_') + } + } + return string(out) +} + +func SortedKeys(m map[string][]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/ACM-42568_go_migration/config.go b/ACM-42568_go_migration/config.go new file mode 100644 index 0000000000..4b35f62ac1 --- /dev/null +++ b/ACM-42568_go_migration/config.go @@ -0,0 +1,140 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +package contract + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +type Mode string + +const ( + ModeAssert Mode = "assert" + ModeCompare Mode = "compare" + ModeRecord Mode = "record" +) + +// Config is loaded from the environment. Tests talk to a running backend (Node or Go). +type Config struct { + BackendURL string + CompareURL string + Token string + PathPrefix string + Mode Mode + RecordDir string + SSETimeout time.Duration + HTTPTimeout time.Duration + InsecureTLS bool +} + +func LoadConfig() Config { + mode := Mode(strings.ToLower(getenv("CONTRACT_MODE", "assert"))) + if mode != ModeCompare && mode != ModeRecord { + mode = ModeAssert + } + if getenv("CONTRACT_COMPARE_URL", "") != "" && mode == ModeAssert { + mode = ModeCompare + } + sse := durationSeconds("CONTRACT_SSE_TIMEOUT", 120) + httpTimeout := durationSeconds("CONTRACT_HTTP_TIMEOUT", 60) + return Config{ + BackendURL: strings.TrimRight(getenv("CONTRACT_BACKEND_URL", "https://localhost:4000"), "/"), + CompareURL: strings.TrimRight(getenv("CONTRACT_COMPARE_URL", ""), "/"), + Token: getenv("CONTRACT_TOKEN", ""), + PathPrefix: strings.TrimRight(getenv("CONTRACT_PATH_PREFIX", ""), "/"), + Mode: mode, + RecordDir: getenv("CONTRACT_RECORD_DIR", "testdata/recorded"), + SSETimeout: sse, + HTTPTimeout: httpTimeout, + InsecureTLS: getenv("CONTRACT_TLS_INSECURE", "true") != "false", + } +} + +func getenv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func durationSeconds(key string, def int) time.Duration { + raw := os.Getenv(key) + if raw == "" { + return time.Duration(def) * time.Second + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return time.Duration(def) * time.Second + } + return time.Duration(n) * time.Second +} + +func (c Config) NewHTTPClient(timeout time.Duration) *http.Client { + if timeout <= 0 { + timeout = c.HTTPTimeout + } + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DisableKeepAlives: true, // avoid stale conn after Client.Timeout on slow hub proxies + DialContext: (&net.Dialer{ + Timeout: 15 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + // Node's HTTP/2 POST pipeline can stall waiting for headers (curl HTTP/1.1 is fine). + // Plugin traffic through the OCP console proxy is HTTP/1.1; match that. + ForceAttemptHTTP2: false, + TLSNextProto: map[string]func(authority string, c *tls.Conn) http.RoundTripper{}, + MaxIdleConns: 20, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 15 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + DisableCompression: true, // observe Content-Encoding + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: c.InsecureTLS, //nolint:gosec // local/dev certs + }, + } + return &http.Client{ + Transport: transport, + Timeout: timeout, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +func (c Config) ResolveURL(base, path string) string { + p := path + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + if c.PathPrefix != "" && !strings.HasPrefix(p, c.PathPrefix+"/") && p != c.PathPrefix { + p = c.PathPrefix + p + } + return strings.TrimRight(base, "/") + p +} + +func (c Config) MulticloudPath(path string) string { + if strings.HasPrefix(path, "/multicloud/") || path == "/multicloud" { + return path + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return "/multicloud" + path +} + +func PrettyJSON(v any) string { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Sprint(v) + } + return string(b) +} diff --git a/ACM-42568_go_migration/contract_test.go b/ACM-42568_go_migration/contract_test.go new file mode 100644 index 0000000000..882484857d --- /dev/null +++ b/ACM-42568_go_migration/contract_test.go @@ -0,0 +1,280 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +package contract + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestMain(m *testing.M) { + code := m.Run() + printContractSummary(os.Stdout) + os.Exit(code) +} + +func catalogDir(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Join(filepath.Dir(file), "catalog") +} + +func TestCatalogAgainstBackend(t *testing.T) { + cfg := LoadConfig() + if cfg.Token == "" { + cfg.Token = ocToken() + } + if err := ProbeBackend(cfg); err != nil { + t.Skipf("backend not reachable at %s: %v (set CONTRACT_BACKEND_URL and start npm run plugins)", cfg.BackendURL, err) + } + if cfg.Token == "" { + t.Fatal("CONTRACT_TOKEN is empty (export CONTRACT_TOKEN=$(oc whoami -t))") + } + + cases, resources, err := LoadCatalog(catalogDir(t)) + if err != nil { + t.Fatal(err) + } + if g := os.Getenv("CONTRACT_GROUP"); g != "" { + filtered := make([]Case, 0, len(cases)) + for _, cs := range cases { + if cs.Group == g { + filtered = append(filtered, cs) + } + } + cases = filtered + if len(cases) == 0 { + t.Fatalf("CONTRACT_GROUP=%q matched no cases", g) + } + } + watched := WatchedKindSet(resources) + t.Logf("backend=%s cases=%d mode=%s", cfg.BackendURL, len(cases), cfg.Mode) + + catalogSummary.reset() + + for _, cs := range cases { + cs := cs + paths := []string{cs.Path} + if cs.AlsoMulticloud { + paths = append(paths, cfg.MulticloudPath(cs.Path)) + } + for _, p := range paths { + p := p + name := cs.ID + if p != cs.Path { + name += "/multicloud" + } + t.Run(name, func(t *testing.T) { + t.Logf("→ %s %s %s", cs.Method, p, cs.Kind) + runCase(t, cfg, cs, p, watched, name) + }) + } + } +} + +func runCase(t *testing.T, cfg Config, cs Case, path string, watched map[string]struct{}, reportName string) { + t.Helper() + + switch strings.ToLower(cs.Kind) { + case "sse": + cap, _, err := cfg.CaptureSSE(cfg.BackendURL, cs, path, timeoutFor(cfg, cs)) + if err != nil { + failOrSkip(t, reportName, wrapSoft(cs, err)) + return + } + if err := AssertCapture(cs, cap, watched); err != nil { + failOrSkip(t, reportName, wrapSoft(cs, err)) + return + } + recordAndCompare(t, cfg, cs, path, cap, reportName) + case "websocket": + if err := cfg.RunWebSocket(cfg.BackendURL, cs, path); err != nil { + failOrSkip(t, reportName, wrapSoft(cs, err)) + return + } + if cfg.CompareURL != "" { + if err := cfg.RunWebSocket(cfg.CompareURL, cs, path); err != nil { + failOrSkip(t, reportName, wrapSoft(cs, err)) + return + } + } + default: + cap, err := cfg.Do(cfg.NewHTTPClient(timeoutFor(cfg, cs)), cfg.BackendURL, cs, path) + if err != nil { + failOrSkip(t, reportName, wrapSoft(cs, err)) + return + } + if err := AssertCapture(cs, cap, watched); err != nil { + failOrSkip(t, reportName, wrapSoft(cs, err)) + return + } + recordAndCompare(t, cfg, cs, path, cap, reportName) + } + catalogSummary.record(reportName, resultPass) +} + +func recordAndCompare(t *testing.T, cfg Config, cs Case, path string, cap Capture, reportName string) { + t.Helper() + if cfg.Mode == ModeRecord || os.Getenv("CONTRACT_RECORD") == "1" { + rec := Recorded{ + ID: cs.ID, + Path: path, + Status: cap.Status, + Headers: NormalizeHeaders(cap.HeaderMap()), + Body: NormalizeBody(cap.Decoded), + } + if err := WriteRecord(cfg.RecordDir, rec); err != nil { + failHard(t, reportName, fmt.Errorf("record: %w", err)) + } + } + if cfg.CompareURL == "" || !shouldCompareREST(cs) { + // SSE/WebSocket shadow-diff is ACM-42598; CONTRACT_COMPARE_URL is REST only. + return + } + other, err := cfg.Do(cfg.NewHTTPClient(timeoutFor(cfg, cs)), cfg.CompareURL, cs, path) + if err != nil { + failHard(t, reportName, fmt.Errorf("compare request: %w", err)) + return + } + if err := DiffCaptures(cap, other); err != nil { + failHard(t, reportName, fmt.Errorf("compare %s: %w", cfg.CompareURL, err)) + } +} + +func failOrSkip(t *testing.T, name string, err error) { + t.Helper() + if err == nil { + return + } + catalogSummary.setDetail(name, err.Error()) + if IsSoftSkip(err) { + catalogSummary.record(name, resultSoft) + t.Skip(err.Error()) + return + } + catalogSummary.record(name, resultFail) + t.Fatal(err) +} + +func failHard(t *testing.T, name string, err error) { + t.Helper() + catalogSummary.setDetail(name, err.Error()) + catalogSummary.record(name, resultFail) + t.Fatal(err) +} + +func wrapSoft(cs Case, err error) error { + if err == nil { + return nil + } + if IsSoftSkip(err) { + return err + } + if cs.Soft { + return skipSoft(err.Error()) + } + return err +} + +func timeoutFor(cfg Config, cs Case) time.Duration { + if cs.TimeoutSeconds > 0 { + return time.Duration(cs.TimeoutSeconds) * time.Second + } + if strings.EqualFold(cs.Kind, "sse") { + return cfg.SSETimeout + } + return cfg.HTTPTimeout +} + +func ocToken() string { + cmd := exec.Command("oc", "whoami", "-t") + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func TestLoadCatalog(t *testing.T) { + cases, resources, err := LoadCatalog(catalogDir(t)) + if err != nil { + t.Fatal(err) + } + if len(cases) < 40 { + t.Fatalf("expected a full catalog, got %d cases", len(cases)) + } + if len(resources) < 67 { + t.Fatalf("expected watched resources, got %d", len(resources)) + } + seen := map[string]struct{}{} + for _, c := range cases { + if c.ID == "" { + t.Fatal("case missing id") + } + if _, ok := seen[c.ID]; ok { + t.Fatalf("duplicate case id %s", c.ID) + } + seen[c.ID] = struct{}{} + } +} + +func TestParseSSE(t *testing.T) { + raw := []byte("id:1\ndata:{\"type\":\"START\"}\n\n: keepalive\n\nid:2\ndata:{\"type\":\"SETTINGS\",\"settings\":{\"LOG_LEVEL\":\"info\"}}\n\nid:3\ndata:{\"type\":\"LOADED\"}\n\n") + events, err := ParseSSE(raw) + if err != nil { + t.Fatal(err) + } + if len(events) != 3 { + t.Fatalf("got %d events: %+v", len(events), events) + } + if sseType(events[0]) != "START" || sseType(events[2]) != "LOADED" { + t.Fatalf("types: %+v", events) + } + exp := &SSEExpect{FirstType: "START", LastType: "LOADED", RequireTypes: []string{"START", "SETTINGS", "LOADED"}, SettingsKeys: []string{"LOG_LEVEL"}} + if err := AssertSSE(exp, events, nil); err != nil { + t.Fatal(err) + } +} + +func TestNormalizeBodyStripsUID(t *testing.T) { + raw := []byte(`{"kind":"Namespace","metadata":{"name":"default","uid":"abc","resourceVersion":"1","creationTimestamp":"2024-01-01T00:00:00Z"}}`) + got := NormalizeBody(raw) + m := got.(map[string]any)["metadata"].(map[string]any) + if _, ok := m["uid"]; ok { + t.Fatal("uid should be stripped") + } + if m["name"] != "default" { + t.Fatalf("name=%v", m["name"]) + } +} + +func TestResolveURL(t *testing.T) { + cfg := Config{BackendURL: "https://localhost:4000"} + if u := cfg.ResolveURL(cfg.BackendURL, "/ping"); u != "https://localhost:4000/ping" { + t.Fatal(u) + } + cfg.PathPrefix = "/api/proxy/plugin/mce/console/multicloud" + if u := cfg.ResolveURL(cfg.BackendURL, "/ping"); !strings.HasSuffix(u, "/api/proxy/plugin/mce/console/multicloud/ping") { + t.Fatal(u) + } +} + +func TestMulticloudPath(t *testing.T) { + cfg := Config{} + if p := cfg.MulticloudPath("/ping"); p != "/multicloud/ping" { + t.Fatal(p) + } +} + +func Example_run() { + fmt.Println("CONTRACT_BACKEND_URL=https://localhost:4000 CONTRACT_TOKEN=$(oc whoami -t) go test ./...") +} diff --git a/ACM-42568_go_migration/events_defs_test.go b/ACM-42568_go_migration/events_defs_test.go new file mode 100644 index 0000000000..28f1a849a3 --- /dev/null +++ b/ACM-42568_go_migration/events_defs_test.go @@ -0,0 +1,203 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +package contract + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" +) + +func eventsTSPath(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Join(filepath.Dir(file), "..", "backend-node", "src", "routes", "events.ts") +} + +func TestWatchedResourcesMatchEventsTS(t *testing.T) { + _, resources, err := LoadCatalog(catalogDir(t)) + if err != nil { + t.Fatal(err) + } + yamlSpecs := EventsWatchSpecs(resources) + if len(yamlSpecs) != 67 { + t.Fatalf("expected 67 events.ts watch specs, got %d", len(yamlSpecs)) + } + + src, err := os.ReadFile(eventsTSPath(t)) + if err != nil { + t.Fatal(err) + } + tsSpecs, err := parseEventsDefinitions(src) + if err != nil { + t.Fatal(err) + } + if len(tsSpecs) != 67 { + t.Fatalf("events.ts definitions: got %d, want 67", len(tsSpecs)) + } + + yamlByKey := map[string]Resource{} + for _, r := range yamlSpecs { + yamlByKey[r.SpecKey()] = r + } + tsByKey := map[string]Resource{} + for _, r := range tsSpecs { + tsByKey[r.SpecKey()] = r + } + for k, ts := range tsByKey { + y, ok := yamlByKey[k] + if !ok { + t.Errorf("missing in watched-resources.yaml: %+v", ts) + continue + } + if y.Polled != ts.Polled { + t.Errorf("%s polled yaml=%v ts=%v", k, y.Polled, ts.Polled) + } + if y.ForwardsToClients() != ts.ForwardsToClients() { + t.Errorf("%s forwardEventsToClients yaml=%v ts=%v", k, y.ForwardsToClients(), ts.ForwardsToClients()) + } + } + for k, y := range yamlByKey { + if _, ok := tsByKey[k]; !ok { + t.Errorf("extra in watched-resources.yaml (not in events.ts): %+v", y) + } + } + + var rbac int + for _, r := range resources { + if r.Source == "events-rbac" { + rbac++ + if r.Kind != "ClusterRole" { + t.Errorf("events-rbac spec should be ClusterRole, got %s", r.Kind) + } + } + } + if rbac != 1 { + t.Fatalf("expected 1 events-rbac spec (ClusterRole), got %d", rbac) + } +} + +var ( + kindRE = regexp.MustCompile(`kind:\s*'([^']+)'`) + apiVersionRE = regexp.MustCompile(`apiVersion:\s*'([^']+)'`) + selectorRE = regexp.MustCompile(`'([^']+)':\s*'([^']*)'`) +) + +func parseEventsDefinitions(src []byte) ([]Resource, error) { + s := string(src) + marker := "const definitions: IWatchOptions[] = [" + start := strings.Index(s, marker) + if start < 0 { + return nil, fmt.Errorf("definitions array not found") + } + rest := s[start+len(marker):] + end := strings.Index(rest, "\nexport function startWatching") + if end < 0 { + return nil, fmt.Errorf("end of definitions not found") + } + body := stripTSLineComments(rest[:end]) + objs := extractTSObjects(body) + out := make([]Resource, 0, len(objs)) + for _, obj := range objs { + r, err := parseTSWatchObject(obj) + if err != nil { + return nil, err + } + out = append(out, r) + } + return out, nil +} + +func stripTSLineComments(s string) string { + lines := strings.Split(s, "\n") + out := make([]string, 0, len(lines)) + for _, line := range lines { + trim := strings.TrimSpace(line) + if strings.HasPrefix(trim, "//") { + continue + } + out = append(out, line) + } + return strings.Join(out, "\n") +} + +func extractTSObjects(s string) []string { + var objs []string + depth := 0 + start := -1 + inQuote := false + for i := 0; i < len(s); i++ { + c := s[i] + if c == '\'' && (i == 0 || s[i-1] != '\\') { + inQuote = !inQuote + continue + } + if inQuote { + continue + } + switch c { + case '{': + if depth == 0 { + start = i + } + depth++ + case '}': + depth-- + if depth == 0 && start >= 0 { + objs = append(objs, s[start:i+1]) + start = -1 + } + } + } + return objs +} + +func parseTSWatchObject(obj string) (Resource, error) { + km := kindRE.FindStringSubmatch(obj) + am := apiVersionRE.FindStringSubmatch(obj) + if km == nil || am == nil { + return Resource{}, fmt.Errorf("kind/apiVersion missing in %s", obj) + } + r := Resource{Kind: km[1], APIVersion: am[1]} + if strings.Contains(obj, "isPolled: true") { + r.Polled = true + } + if strings.Contains(obj, "forwardEventsToClients: false") { + f := false + r.ForwardEventsToClients = &f + } + if i := strings.Index(obj, "labelSelector:"); i >= 0 { + r.LabelSelector = parseTSSelector(obj[i:]) + } + if i := strings.Index(obj, "fieldSelector:"); i >= 0 { + r.FieldSelector = parseTSSelector(obj[i:]) + } + return r, nil +} + +func parseTSSelector(s string) map[string]string { + brace := strings.Index(s, "{") + if brace < 0 { + return nil + } + end := strings.Index(s[brace:], "}") + if end < 0 { + return nil + } + inner := s[brace : brace+end] + out := map[string]string{} + for _, m := range selectorRE.FindAllStringSubmatch(inner, -1) { + out[m[1]] = m[2] + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/ACM-42568_go_migration/go.mod b/ACM-42568_go_migration/go.mod new file mode 100644 index 0000000000..0fd2fd10f0 --- /dev/null +++ b/ACM-42568_go_migration/go.mod @@ -0,0 +1,8 @@ +module github.com/stolostron/console/acm-42568-contract + +go 1.22 + +require ( + github.com/gorilla/websocket v1.5.3 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/ACM-42568_go_migration/go.sum b/ACM-42568_go_migration/go.sum new file mode 100644 index 0000000000..6a2c68db47 --- /dev/null +++ b/ACM-42568_go_migration/go.sum @@ -0,0 +1,6 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ACM-42568_go_migration/normalize.go b/ACM-42568_go_migration/normalize.go new file mode 100644 index 0000000000..a416953a6b --- /dev/null +++ b/ACM-42568_go_migration/normalize.go @@ -0,0 +1,127 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +package contract + +import ( + "bytes" + "encoding/json" + "regexp" + "sort" + "strings" +) + +var isoTime = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}`) + +var volatileKeys = map[string]struct{}{ + "resourceversion": {}, + "uid": {}, + "creationtimestamp": {}, + "managedfields": {}, + "generation": {}, + "selflink": {}, + "rv": {}, +} + +func NormalizeHeaders(h map[string][]string) map[string][]string { + out := map[string][]string{} + skip := map[string]struct{}{ + "date": {}, "content-length": {}, "transfer-encoding": {}, + "connection": {}, "keep-alive": {}, "alt-svc": {}, + } + for k, vals := range h { + lk := strings.ToLower(k) + if _, ok := skip[lk]; ok { + continue + } + cleaned := make([]string, 0, len(vals)) + for _, v := range vals { + if lk == "set-cookie" { + cleaned = append(cleaned, normalizeSetCookie(v)) + continue + } + cleaned = append(cleaned, v) + } + sort.Strings(cleaned) + out[lk] = cleaned + } + return out +} + +func normalizeSetCookie(v string) string { + parts := strings.Split(v, ";") + name := strings.TrimSpace(parts[0]) + if i := strings.IndexByte(name, '='); i >= 0 { + name = name[:i] + } + attrs := []string{strings.ToLower(name) + "="} + for _, p := range parts[1:] { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if i := strings.IndexByte(p, '='); i >= 0 { + attrs = append(attrs, strings.ToLower(p[:i])) + } else { + attrs = append(attrs, strings.ToLower(p)) + } + } + sort.Strings(attrs[1:]) + return strings.Join(attrs, "; ") +} + +func NormalizeBody(raw []byte) any { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 { + return "" + } + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return string(raw) + } + return stripVolatile(v) +} + +func stripVolatile(v any) any { + switch t := v.(type) { + case map[string]any: + out := make(map[string]any, len(t)) + for k, val := range t { + if _, skip := volatileKeys[strings.ToLower(k)]; skip { + continue + } + out[k] = stripVolatile(val) + } + return out + case []any: + out := make([]any, len(t)) + for i, item := range t { + out[i] = stripVolatile(item) + } + return out + case string: + if isoTime.MatchString(t) { + return "" + } + return t + default: + return t + } +} + +func HeaderValuesCI(h map[string][]string, name string) []string { + want := strings.ToLower(name) + for k, v := range h { + if strings.ToLower(k) == want { + return v + } + } + return nil +} + +func FirstHeader(h map[string][]string, name string) string { + vals := HeaderValuesCI(h, name) + if len(vals) == 0 { + return "" + } + return vals[0] +} diff --git a/ACM-42568_go_migration/report.go b/ACM-42568_go_migration/report.go new file mode 100644 index 0000000000..cbd2c1be2f --- /dev/null +++ b/ACM-42568_go_migration/report.go @@ -0,0 +1,161 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +package contract + +import ( + "fmt" + "io" + "os" + "strings" + "sync" +) + +const ( + resultPass = "pass" + resultFail = "fail" + resultSoft = "soft" +) + +type caseOutcome struct { + name string + status string + detail string +} + +type runSummary struct { + mu sync.Mutex + outcomes []caseOutcome + details map[string]string +} + +var catalogSummary runSummary + +func (s *runSummary) reset() { + s.mu.Lock() + s.outcomes = nil + s.details = map[string]string{} + s.mu.Unlock() +} + +func (s *runSummary) setDetail(name, detail string) { + s.mu.Lock() + if s.details == nil { + s.details = map[string]string{} + } + s.details[name] = detail + s.mu.Unlock() +} + +func (s *runSummary) record(name, status string) { + s.mu.Lock() + detail := "" + if s.details != nil { + detail = s.details[name] + } + s.outcomes = append(s.outcomes, caseOutcome{name: name, status: status, detail: detail}) + s.mu.Unlock() +} + +func (s *runSummary) snapshot() []caseOutcome { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]caseOutcome, len(s.outcomes)) + copy(out, s.outcomes) + return out +} + +func colorEnabled() bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + return true +} + +func ansi(code string, text string) string { + if !colorEnabled() { + return text + } + return code + text + "\033[0m" +} + +func green(text string) string { + return ansi("\033[32m", text) +} + +func red(text string) string { + return ansi("\033[31m", text) +} + +func yellow(text string) string { + return ansi("\033[33m", text) +} + +func bold(text string) string { + return ansi("\033[1m", text) +} + +func printContractSummary(w io.Writer) { + outcomes := catalogSummary.snapshot() + if len(outcomes) == 0 { + return + } + + var passed, failed, soft int + var failLines, softLines []string + for _, o := range outcomes { + switch o.status { + case resultPass: + passed++ + case resultFail: + failed++ + line := o.name + if o.detail != "" { + line += " — " + o.detail + } + failLines = append(failLines, line) + case resultSoft: + soft++ + line := o.name + if o.detail != "" { + line += " — " + o.detail + } + softLines = append(softLines, line) + } + } + + executed := len(outcomes) + width := 52 + sep := strings.Repeat("═", width) + + fmt.Fprintln(w) + fmt.Fprintln(w, bold(sep)) + fmt.Fprintln(w, bold(" Contract test summary")) + fmt.Fprintln(w, bold(sep)) + fmt.Fprintf(w, " Executed: %d\n", executed) + fmt.Fprintf(w, " %s: %d\n", green("OK"), passed) + fmt.Fprintf(w, " %s: %d\n", yellow("SOFT (skipped)"), soft) + fmt.Fprintf(w, " %s: %d\n", red("FAIL"), failed) + fmt.Fprintln(w, bold(sep)) + + if len(failLines) > 0 { + fmt.Fprintln(w, red(" Failed:")) + for _, line := range failLines { + fmt.Fprintf(w, " %s\n", red(line)) + } + } + if len(softLines) > 0 { + fmt.Fprintln(w, yellow(" Soft skips (optional upstream missing):")) + for _, line := range softLines { + fmt.Fprintf(w, " %s\n", yellow(line)) + } + } + if failed == 0 && soft == 0 { + fmt.Fprintln(w, green(" All mandatory contract cases passed.")) + } else if failed == 0 { + fmt.Fprintln(w, green(" Mandatory cases passed.")+" "+yellow(fmt.Sprintf("%d optional case(s) skipped.", soft))) + } else { + fmt.Fprintln(w, red(fmt.Sprintf("%d case(s) failed — migration gate not green.", failed))) + } + fmt.Fprintln(w, bold(sep)) + fmt.Fprintln(w) +} diff --git a/ACM-42568_go_migration/report_test.go b/ACM-42568_go_migration/report_test.go new file mode 100644 index 0000000000..1795826861 --- /dev/null +++ b/ACM-42568_go_migration/report_test.go @@ -0,0 +1,40 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +package contract + +import ( + "bytes" + "strings" + "testing" +) + +func TestPrintContractSummary(t *testing.T) { + catalogSummary.reset() + catalogSummary.setDetail("fail-case", "status 500") + catalogSummary.record("ok-case", resultPass) + catalogSummary.record("soft-case", resultSoft) + catalogSummary.record("fail-case", resultFail) + + var buf bytes.Buffer + printContractSummary(&buf) + out := buf.String() + + if !strings.Contains(out, "Executed: 3") { + t.Fatalf("expected executed count: %s", out) + } + if !strings.Contains(out, "OK") || !strings.Contains(out, "SOFT") || !strings.Contains(out, "FAIL") { + t.Fatalf("missing summary labels: %s", out) + } + if !strings.Contains(out, "ok-case") && !strings.Contains(out, "soft-case") && !strings.Contains(out, "fail-case") { + t.Fatalf("expected case names in detail lists: %s", out) + } +} + +func TestPrintContractSummaryEmpty(t *testing.T) { + catalogSummary.reset() + var buf bytes.Buffer + printContractSummary(&buf) + if buf.Len() != 0 { + t.Fatalf("empty summary should not print: %s", buf.String()) + } +} diff --git a/ACM-42568_go_migration/run-catalog.sh b/ACM-42568_go_migration/run-catalog.sh new file mode 100755 index 0000000000..52036eb9c9 --- /dev/null +++ b/ACM-42568_go_migration/run-catalog.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Copyright Contributors to the Open Cluster Management project +set -euo pipefail + +cd "$(dirname "$0")" +MODULE_DIR="$(pwd)" +ROOT_DIR="$(cd "$MODULE_DIR/.." && pwd)" + +export CONTRACT_BACKEND_URL="${CONTRACT_BACKEND_URL:-https://localhost:4000}" +if [[ -z "${CONTRACT_TOKEN:-}" ]]; then + CONTRACT_TOKEN="$(oc whoami -t 2>/dev/null || true)" + export CONTRACT_TOKEN +fi + +TIMEOUT="${CONTRACT_TIMEOUT:-30m}" +# Many catalog cases hit hub proxies; 60s per case with no -test.v looks like a hang. +export CONTRACT_HTTP_TIMEOUT="${CONTRACT_HTTP_TIMEOUT:-30}" +export CONTRACT_SSE_TIMEOUT="${CONTRACT_SSE_TIMEOUT:-90}" +VERBOSE="${CONTRACT_VERBOSE:-1}" +BIN="${TMPDIR:-/tmp}/acm-42590-contract.test" + +preflight() { + if [[ "${CONTRACT_SKIP_PREFLIGHT:-}" == "1" ]]; then + echo "Preflight skipped (CONTRACT_SKIP_PREFLIGHT=1)" + return 0 + fi + + if [[ -x "$ROOT_DIR/scripts/check-hub-alignment.sh" ]]; then + "$ROOT_DIR/scripts/check-hub-alignment.sh" + else + ENV_FILE="${ROOT_DIR}/backend/.env" + if command -v oc >/dev/null 2>&1; then + OC_SERVER="$(oc whoami --show-server 2>/dev/null || true)" + if [[ -n "$OC_SERVER" && -f "$ENV_FILE" ]]; then + CLUSTER_API_URL="$(grep -E '^CLUSTER_API_URL=' "$ENV_FILE" | cut -d= -f2- || true)" + OC_SERVER="${OC_SERVER%/}" + CLUSTER_API_URL="${CLUSTER_API_URL%/}" + if [[ -n "$CLUSTER_API_URL" && "$OC_SERVER" != "$CLUSTER_API_URL" ]]; then + cat >&2 <&2 <&2 < ${http_code:-curl-failed}) + +Quick check: curl -sk ${ping_url} +Start npm run plugins from the repo root. You only need the backend on :4000 +(OpenShift Console on :9000 is optional). + +If the URL is https:// and curl reports a TLS handshake error, run: + npm run generate-certs +and restart npm run plugins. +EOF + exit 1 + fi + if [[ "$static_code" != "404" ]]; then + if [[ "$http_code" != "200" ]]; then + cat >&2 < ${http_code:-curl-failed}) + +Start npm run plugins from the repo root, then retry. +EOF + exit 1 + fi + cat >&2 < ${static_code:-curl-failed}, want 404) + +/ping returned 200 but the static handler did not respond in time. The Go listener is up +but request handlers are stuck (often after a long catalog run on an old backend binary). + +Fix — in the terminal running npm run plugins: + 1. Ctrl+C + 2. cd ${ROOT_DIR} && npm run plugins + 3. Wait for [go] server start, then verify: + curl -sk ${static_url} -o /dev/null -w '%{http_code}\\n' + (should print 404 within 1s) + +To skip this check: CONTRACT_SKIP_PREFLIGHT=1 ./run-catalog.sh +EOF + exit 1 + fi +} + +echo "Backend: $CONTRACT_BACKEND_URL" +preflight + +echo "Building test binary..." +go test -c -o "$BIN" . + +echo "Running full catalog (TestCatalogAgainstBackend)..." +echo " HTTP timeout=${CONTRACT_HTTP_TIMEOUT}s SSE timeout=${CONTRACT_SSE_TIMEOUT}s (override with CONTRACT_HTTP_TIMEOUT / CONTRACT_SSE_TIMEOUT)" +if [[ "$VERBOSE" == "1" ]]; then + echo " Per-case progress enabled (CONTRACT_VERBOSE=0 to hide)" +fi +test_args=(-test.timeout="$TIMEOUT" -test.run=TestCatalogAgainstBackend) +if [[ "$VERBOSE" == "1" ]]; then + test_args+=(-test.v) +fi +# Line-buffer stdout so per-case -test.v lines appear immediately (otherwise looks stuck). +if command -v stdbuf >/dev/null 2>&1; then + exec stdbuf -oL -eL "$BIN" "${test_args[@]}" +else + exec "$BIN" "${test_args[@]}" +fi diff --git a/ACM-42568_go_migration/snapshot.go b/ACM-42568_go_migration/snapshot.go new file mode 100644 index 0000000000..75619d4cf8 --- /dev/null +++ b/ACM-42568_go_migration/snapshot.go @@ -0,0 +1,165 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +package contract + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "sort" + "strings" + "time" +) + +// ResourceKey is the normalized cache identity used by the ACM-42597 snapshot gate. +type ResourceKey struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name"` + UID string `json:"uid,omitempty"` +} + +func (k ResourceKey) CompareKey() string { + return k.APIVersion + "|" + k.Kind + "|" + k.Namespace + "|" + k.Name +} + +// SnapshotDoc is the JSON body of GET /debug/informer-snapshot (Go, ACM-42597) +// or a file produced by compare-informer-cache.sh. +type SnapshotDoc struct { + Synced bool `json:"synced,omitempty"` + Items []ResourceKey `json:"items"` +} + +func SelectorQuery(m map[string]string) string { + if len(m) == 0 { + return "" + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, k+"="+m[k]) + } + return strings.Join(parts, ",") +} + +func NormalizeSnapshotKeys(items []ResourceKey) []ResourceKey { + out := make([]ResourceKey, len(items)) + for i, k := range items { + out[i] = ResourceKey{ + APIVersion: k.APIVersion, + Kind: k.Kind, + Namespace: k.Namespace, + Name: k.Name, + } + } + sort.Slice(out, func(i, j int) bool { + return out[i].CompareKey() < out[j].CompareKey() + }) + return out +} + +func ExcludePolled(items []ResourceKey, resources []Resource) []ResourceKey { + polled := map[string]struct{}{} + for _, r := range resources { + if r.Polled { + polled[r.APIVersion+"|"+r.Kind] = struct{}{} + } + } + if len(polled) == 0 { + return items + } + out := make([]ResourceKey, 0, len(items)) + for _, k := range items { + if _, skip := polled[k.APIVersion+"|"+k.Kind]; skip { + continue + } + out = append(out, k) + } + return out +} + +func DiffSnapshots(a, b []ResourceKey) error { + na := NormalizeSnapshotKeys(a) + nb := NormalizeSnapshotKeys(b) + sa := map[string]struct{}{} + sb := map[string]struct{}{} + for _, k := range na { + sa[k.CompareKey()] = struct{}{} + } + for _, k := range nb { + sb[k.CompareKey()] = struct{}{} + } + var onlyA, onlyB []string + for k := range sa { + if _, ok := sb[k]; !ok { + onlyA = append(onlyA, k) + } + } + for k := range sb { + if _, ok := sa[k]; !ok { + onlyB = append(onlyB, k) + } + } + sort.Strings(onlyA) + sort.Strings(onlyB) + if len(onlyA) == 0 && len(onlyB) == 0 { + return nil + } + return fmt.Errorf("snapshot mismatch: only A (%d): %v; only B (%d): %v", len(onlyA), truncateList(onlyA, 20), len(onlyB), truncateList(onlyB, 20)) +} + +func truncateList(s []string, n int) []string { + if len(s) <= n { + return s + } + return append(s[:n], fmt.Sprintf("… +%d more", len(s)-n)) +} + +func LoadSnapshotFile(path string) (SnapshotDoc, error) { + b, err := os.ReadFile(path) + if err != nil { + return SnapshotDoc{}, err + } + var doc SnapshotDoc + if err := json.Unmarshal(b, &doc); err != nil { + return SnapshotDoc{}, err + } + return doc, nil +} + +func FetchSnapshot(cfg Config, rawURL string) (SnapshotDoc, int, error) { + client := cfg.NewHTTPClient(15 * time.Second) + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return SnapshotDoc{}, 0, err + } + if cfg.Token != "" { + req.Header.Set("Authorization", "Bearer "+cfg.Token) + } + resp, err := client.Do(req) + if err != nil { + return SnapshotDoc{}, 0, err + } + defer resp.Body.Close() + var doc SnapshotDoc + if resp.StatusCode != http.StatusOK { + return SnapshotDoc{}, resp.StatusCode, fmt.Errorf("GET %s -> %d", rawURL, resp.StatusCode) + } + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return SnapshotDoc{}, resp.StatusCode, err + } + return doc, resp.StatusCode, nil +} + +// shouldCompareREST is the ACM-42598 hook: CONTRACT_COMPARE_URL diffs REST only. +// SSE and WebSocket are skipped until a dedicated shadow-diff exists. +func shouldCompareREST(cs Case) bool { + kind := strings.ToLower(cs.Kind) + return kind != "sse" && kind != "websocket" +} diff --git a/ACM-42568_go_migration/snapshot_test.go b/ACM-42568_go_migration/snapshot_test.go new file mode 100644 index 0000000000..f9a55775ab --- /dev/null +++ b/ACM-42568_go_migration/snapshot_test.go @@ -0,0 +1,194 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +package contract + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestSelectorQuery(t *testing.T) { + got := SelectorQuery(map[string]string{ + "metadata.namespace": "multicluster-engine", + "metadata.name": "cluster-proxy-addon-user", + }) + want := "metadata.name=cluster-proxy-addon-user,metadata.namespace=multicluster-engine" + if got != want { + t.Fatalf("got %q want %q", got, want) + } + if SelectorQuery(nil) != "" { + t.Fatal("empty selector") + } +} + +func TestDiffSnapshots(t *testing.T) { + a := []ResourceKey{ + {APIVersion: "v1", Kind: "Namespace", Name: "default"}, + {APIVersion: "v1", Kind: "Namespace", Name: "kube-system"}, + } + b := []ResourceKey{ + {APIVersion: "v1", Kind: "Namespace", Name: "kube-system", UID: "ignored"}, + {APIVersion: "v1", Kind: "Namespace", Name: "default"}, + } + if err := DiffSnapshots(a, b); err != nil { + t.Fatal(err) + } + c := []ResourceKey{{APIVersion: "v1", Kind: "Namespace", Name: "default"}} + if err := DiffSnapshots(a, c); err == nil { + t.Fatal("expected mismatch") + } +} + +func TestExcludePolled(t *testing.T) { + items := []ResourceKey{ + {APIVersion: "argoproj.io/v1alpha1", Kind: "Application", Name: "app", Namespace: "ns"}, + {APIVersion: "v1", Kind: "Namespace", Name: "default"}, + {APIVersion: "config.openshift.io/v1", Kind: "Authentication", Name: "cluster"}, + } + resources := []Resource{ + {Kind: "Application", APIVersion: "argoproj.io/v1alpha1", Polled: true}, + {Kind: "Namespace", APIVersion: "v1"}, + {Kind: "Authentication", APIVersion: "config.openshift.io/v1"}, + } + got := ExcludePolled(items, resources) + if len(got) != 2 { + t.Fatalf("got %d", len(got)) + } + for _, k := range got { + if k.Kind == "Application" { + t.Fatal("polled Application should be excluded") + } + } +} + +func TestShouldCompareREST(t *testing.T) { + if shouldCompareREST(Case{Kind: "sse"}) { + t.Fatal("sse must not compare (ACM-42598)") + } + if shouldCompareREST(Case{Kind: "websocket"}) { + t.Fatal("websocket must not compare") + } + if !shouldCompareREST(Case{Kind: "rest"}) { + t.Fatal("rest should compare") + } +} + +func TestCacheSnapshotSkipWhenMissing(t *testing.T) { + cfg := LoadConfig() + if cfg.Token == "" { + cfg.Token = ocToken() + } + goURL := os.Getenv("CONTRACT_GO_SNAPSHOT_URL") + if goURL == "" { + goURL = cfg.ResolveURL(cfg.BackendURL, "/debug/informer-snapshot") + } + nodeURL := os.Getenv("CONTRACT_NODE_SNAPSHOT_URL") + nodeFile := os.Getenv("CONTRACT_NODE_SNAPSHOT_FILE") + goFile := os.Getenv("CONTRACT_GO_SNAPSHOT_FILE") + + var goDoc SnapshotDoc + var haveGo bool + if goFile != "" { + doc, err := LoadSnapshotFile(goFile) + if err != nil { + t.Fatal(err) + } + goDoc = doc + haveGo = true + } else { + doc, status, err := FetchSnapshot(cfg, goURL) + if err != nil || status == http.StatusNotFound { + t.Skipf("Go informer snapshot not available at %s (ACM-42597 not wired yet): %v", goURL, err) + } + if status != http.StatusOK { + t.Skipf("Go snapshot GET %s -> %d", goURL, status) + } + goDoc = doc + haveGo = true + } + + var nodeDoc SnapshotDoc + var haveNode bool + switch { + case nodeFile != "": + doc, err := LoadSnapshotFile(nodeFile) + if err != nil { + t.Fatal(err) + } + nodeDoc = doc + haveNode = true + case nodeURL != "": + doc, status, err := FetchSnapshot(cfg, nodeURL) + if err != nil || status != http.StatusOK { + t.Skipf("Node snapshot not available at %s: %v", nodeURL, err) + } + nodeDoc = doc + haveNode = true + } + + if !haveGo { + t.Skip("no Go snapshot") + } + _, resources, err := LoadCatalog(catalogDir(t)) + if err != nil { + t.Fatal(err) + } + goItems := ExcludePolled(goDoc.Items, resources) + if !haveNode { + t.Logf("Go snapshot items=%d (polled excluded); set CONTRACT_NODE_SNAPSHOT_URL or CONTRACT_NODE_SNAPSHOT_FILE to compare", len(goItems)) + return + } + nodeItems := ExcludePolled(nodeDoc.Items, resources) + if err := DiffSnapshots(nodeItems, goItems); err != nil { + t.Fatal(err) + } +} + +func TestFetchSnapshotOK(t *testing.T) { + doc := SnapshotDoc{Items: []ResourceKey{{APIVersion: "v1", Kind: "Namespace", Name: "default"}}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(doc) + })) + defer srv.Close() + got, status, err := FetchSnapshot(Config{HTTPTimeout: 5 * time.Second, InsecureTLS: true}, srv.URL) + if err != nil { + t.Fatal(err) + } + if status != 200 || len(got.Items) != 1 { + t.Fatalf("status=%d items=%d", status, len(got.Items)) + } +} + +func TestLoadSnapshotFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "snap.json") + body := `{"items":[{"apiVersion":"v1","kind":"Namespace","name":"default"}]}` + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + doc, err := LoadSnapshotFile(path) + if err != nil { + t.Fatal(err) + } + if len(doc.Items) != 1 || doc.Items[0].Name != "default" { + t.Fatalf("%+v", doc) + } +} + +func TestResourceSpecKeyStable(t *testing.T) { + a := Resource{Kind: "Secret", APIVersion: "v1", LabelSelector: map[string]string{"cluster.open-cluster-management.io/type": "ans"}} + b := Resource{Kind: "Secret", APIVersion: "v1", LabelSelector: map[string]string{"cluster.open-cluster-management.io/type": "ans"}} + if a.SpecKey() != b.SpecKey() { + t.Fatal(a.SpecKey()) + } + if !strings.Contains(a.SpecKey(), "cluster.open-cluster-management.io/type=ans") { + t.Fatal(a.SpecKey()) + } +} diff --git a/ACM-42568_go_migration/sse.go b/ACM-42568_go_migration/sse.go new file mode 100644 index 0000000000..b1cb910cb9 --- /dev/null +++ b/ACM-42568_go_migration/sse.go @@ -0,0 +1,277 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +package contract + +import ( + "bufio" + "bytes" + "compress/flate" + "compress/gzip" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type SSEEvent struct { + ID string + Event string + Data string + Parsed map[string]any +} + +func ParseSSE(body []byte) ([]SSEEvent, error) { + var events []SSEEvent + scanner := bufio.NewScanner(bytes.NewReader(body)) + scanner.Buffer(make([]byte, 0, 64*1024), 8<<20) + var cur SSEEvent + flush := func() { + if cur.ID == "" && cur.Event == "" && cur.Data == "" { + return + } + if cur.Data != "" { + var parsed any + if json.Unmarshal([]byte(cur.Data), &parsed) == nil { + if m, ok := parsed.(map[string]any); ok { + cur.Parsed = m + } + } + } + events = append(events, cur) + cur = SSEEvent{} + } + for scanner.Scan() { + line := scanner.Text() + if line == "" { + flush() + continue + } + if strings.HasPrefix(line, ":") { + continue // keepalive comment + } + field, value, _ := strings.Cut(line, ":") + value = strings.TrimPrefix(value, " ") + switch field { + case "id": + cur.ID = value + case "event": + cur.Event = value + case "data": + if cur.Data != "" { + cur.Data += "\n" + } + cur.Data += value + } + } + flush() + return events, scanner.Err() +} + +func (cfg Config) CaptureSSE(base string, cs Case, path string, timeout time.Duration) (Capture, []SSEEvent, error) { + if timeout <= 0 { + timeout = cfg.SSETimeout + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + client := cfg.NewHTTPClient(0) + client.Timeout = 0 + + url := cfg.ResolveURL(base, path) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return Capture{}, nil, err + } + for k, v := range cs.Headers { + req.Header.Set(k, v) + } + if req.Header.Get("Accept") == "" { + req.Header.Set("Accept", "text/event-stream") + } + cfg.applyAuth(req, cs.Auth) + + resp, err := client.Do(req) + if err != nil { + return Capture{}, nil, err + } + defer resp.Body.Close() + // Unblock readUntilLoaded when the context times out (Read ignores ctx). + go func() { + <-ctx.Done() + _ = resp.Body.Close() + }() + + headers := resp.Header.Clone() + stream := io.Reader(resp.Body) + if enc := strings.ToLower(headers.Get("Content-Encoding")); enc == "gzip" { + gr, err := gzip.NewReader(resp.Body) + if err != nil { + return Capture{Status: resp.StatusCode, Headers: headers}, nil, fmt.Errorf("sse gzip: %w", err) + } + defer gr.Close() + stream = gr + } else if enc == "deflate" { + fr := flate.NewReader(resp.Body) + defer fr.Close() + stream = fr + } + decoded, err := readUntilLoaded(ctx, stream) + if err != nil && len(decoded) == 0 { + return Capture{}, nil, err + } + events, parseErr := ParseSSE(decoded) + cap := Capture{Status: resp.StatusCode, Headers: headers, Body: decoded, Decoded: decoded} + if parseErr != nil { + return cap, events, parseErr + } + return cap, events, nil +} + +func readUntilLoaded(ctx context.Context, r io.Reader) ([]byte, error) { + var out bytes.Buffer + tmp := make([]byte, 32*1024) + for { + if ctx.Err() != nil { + return out.Bytes(), ctx.Err() + } + n, err := r.Read(tmp) + if n > 0 { + out.Write(tmp[:n]) + if bytes.Contains(out.Bytes(), []byte(`"type":"LOADED"`)) || bytes.Contains(out.Bytes(), []byte(`"type": "LOADED"`)) { + return out.Bytes(), nil + } + } + if err == io.EOF { + return out.Bytes(), nil + } + if err != nil { + if out.Len() > 0 { + return out.Bytes(), nil + } + return out.Bytes(), err + } + } +} + +func AssertSSE(exp *SSEExpect, events []SSEEvent, watched map[string]struct{}) error { + if len(events) == 0 { + return fmt.Errorf("no SSE events parsed") + } + types := make([]string, 0, len(events)) + typeSet := map[string]struct{}{} + for _, e := range events { + t := sseType(e) + if t == "" { + continue + } + types = append(types, t) + typeSet[t] = struct{}{} + } + if exp.FirstType != "" { + first := firstNonEmpty(types) + if first != exp.FirstType { + return fmt.Errorf("first SSE type %q, want %q", first, exp.FirstType) + } + } + if exp.LastType != "" { + last := lastNonEmpty(types) + if last != exp.LastType { + return fmt.Errorf("last SSE type %q, want %q (types=%v)", last, exp.LastType, summarizeTypes(types)) + } + } + for _, t := range exp.RequireTypes { + if _, ok := typeSet[t]; !ok { + return fmt.Errorf("missing SSE type %s; saw %v", t, summarizeTypes(types)) + } + } + for _, e := range events { + t := sseType(e) + switch t { + case "ADDED", "MODIFIED", "DELETED": + if exp.ObjectHasKindAPIVersionName { + obj, _ := e.Parsed["object"].(map[string]any) + if obj == nil { + return fmt.Errorf("%s event missing object: %s", t, e.Data) + } + if str(obj["kind"]) == "" || str(obj["apiVersion"]) == "" { + return fmt.Errorf("%s object missing kind/apiVersion", t) + } + meta, _ := obj["metadata"].(map[string]any) + if meta == nil || str(meta["name"]) == "" { + return fmt.Errorf("%s object missing metadata.name", t) + } + } + if exp.KindsSubsetOfWatched && len(watched) > 0 { + obj, _ := e.Parsed["object"].(map[string]any) + if obj != nil { + k := str(obj["kind"]) + if k != "" { + if _, ok := watched[k]; !ok { + return fmt.Errorf("unexpected watched kind %s", k) + } + } + } + } + case "SETTINGS": + settings, _ := e.Parsed["settings"].(map[string]any) + for _, key := range exp.SettingsKeys { + if settings == nil { + return fmt.Errorf("SETTINGS missing settings object") + } + if _, ok := settings[key]; !ok { + return fmt.Errorf("SETTINGS missing key %s", key) + } + } + } + } + return nil +} + +func sseType(e SSEEvent) string { + if e.Parsed == nil { + return "" + } + return str(e.Parsed["type"]) +} + +func str(v any) string { + s, _ := v.(string) + return s +} + +func firstNonEmpty(s []string) string { + for _, v := range s { + if v != "" { + return v + } + } + return "" +} + +func lastNonEmpty(s []string) string { + for i := len(s) - 1; i >= 0; i-- { + if s[i] != "" { + return s[i] + } + } + return "" +} + +func summarizeTypes(types []string) []string { + counts := map[string]int{} + order := []string{} + for _, t := range types { + if counts[t] == 0 { + order = append(order, t) + } + counts[t]++ + } + out := make([]string, 0, len(order)) + for _, t := range order { + out = append(out, fmt.Sprintf("%s×%d", t, counts[t])) + } + return out +} diff --git a/ACM-42568_go_migration/ws.go b/ACM-42568_go_migration/ws.go new file mode 100644 index 0000000000..9b66323980 --- /dev/null +++ b/ACM-42568_go_migration/ws.go @@ -0,0 +1,109 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +package contract + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gorilla/websocket" +) + +func (cfg Config) DialWS(base string, cs Case, path string) (*websocket.Conn, *http.Response, error) { + url := cfg.ResolveURL(base, path) + url = toWSURL(url) + hdr := http.Header{} + for k, v := range cs.Headers { + hdr.Set(k, v) + } + req := &http.Request{Header: hdr} + cfg.applyAuth(req, cs.Auth) + hdr = req.Header + + dialer := websocket.Dialer{ + HandshakeTimeout: 15 * time.Second, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: cfg.InsecureTLS, //nolint:gosec + }, + } + if cs.WS != nil && cs.WS.Subprotocol != "" { + dialer.Subprotocols = []string{cs.WS.Subprotocol} + } + return dialer.Dial(url, hdr) +} + +func (cfg Config) RunWebSocket(base string, cs Case, path string) error { + timeout := time.Duration(cs.TimeoutSeconds) * time.Second + if timeout <= 0 { + timeout = 20 * time.Second + } + conn, resp, err := cfg.DialWS(base, cs, path) + if err != nil { + status := 0 + if resp != nil { + status = resp.StatusCode + } + if cs.Soft && (containsInt(cs.SoftStatuses, status) || status == 0) { + return skipSoft(fmt.Sprintf("websocket dial: %v (status %d)", err, status)) + } + return fmt.Errorf("websocket dial: %w (status %d)", err, status) + } + defer conn.Close() + + if cs.WS != nil && cs.WS.ExpectUpgrade { + return nil + } + deadline := time.Now().Add(timeout) + _ = conn.SetReadDeadline(deadline) + _ = conn.SetWriteDeadline(deadline) + + if cs.WS != nil { + for _, msg := range cs.WS.Send { + if err := conn.WriteMessage(websocket.TextMessage, []byte(msg)); err != nil { + if cs.Soft { + return skipSoft("ws write: " + err.Error()) + } + return fmt.Errorf("ws write: %w", err) + } + } + if cs.WS.ExpectType != "" { + _, payload, err := conn.ReadMessage() + if err != nil { + if cs.Soft { + return skipSoft("ws read: " + err.Error()) + } + return fmt.Errorf("ws read: %w", err) + } + var parsed map[string]any + if err := json.Unmarshal(payload, &parsed); err != nil { + if cs.Soft { + return skipSoft("ws json: " + err.Error()) + } + return fmt.Errorf("ws json: %w body=%s", err, truncate(payload, 200)) + } + got := str(parsed["type"]) + if got != cs.WS.ExpectType { + if cs.Soft { + return skipSoft(fmt.Sprintf("ws type %q want %q", got, cs.WS.ExpectType)) + } + return fmt.Errorf("ws type %q, want %q (payload=%s)", got, cs.WS.ExpectType, truncate(payload, 200)) + } + } + } + return nil +} + +func toWSURL(u string) string { + switch { + case strings.HasPrefix(u, "https://"): + return "wss://" + strings.TrimPrefix(u, "https://") + case strings.HasPrefix(u, "http://"): + return "ws://" + strings.TrimPrefix(u, "http://") + default: + return u + } +}