From ef4c34c73bcc3a75a04ccb6709f90e6081e90d7d Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Tue, 1 Sep 2026 07:32:50 +0200 Subject: [PATCH 1/9] Contract/golden test framework for backend migration Signed-off-by: Enrique Mingorance Cano --- ACM-42568_go_migration/.gitignore | 6 + ACM-42568_go_migration/QUIRKS.md | 112 ++++++++ ACM-42568_go_migration/README.md | 64 +++++ ACM-42568_go_migration/assert.go | 169 +++++++++++ ACM-42568_go_migration/catalog.go | 126 ++++++++ ACM-42568_go_migration/catalog/00-probes.yaml | 34 +++ .../catalog/10-kube-proxy.yaml | 196 +++++++++++++ .../catalog/20-metrics-mcp-vm-serve.yaml | 116 ++++++++ .../catalog/30-auth-cluster.yaml | 185 ++++++++++++ .../catalog/40-aggregate-search.yaml | 72 +++++ ACM-42568_go_migration/catalog/50-sse.yaml | 31 ++ .../catalog/60-websocket.yaml | 32 +++ .../catalog/70-long-tail.yaml | 232 +++++++++++++++ .../catalog/80-negative.yaml | 132 +++++++++ .../catalog/watched-resources.yaml | 127 ++++++++ ACM-42568_go_migration/client.go | 134 +++++++++ ACM-42568_go_migration/compare.go | 82 ++++++ ACM-42568_go_migration/config.go | 139 +++++++++ ACM-42568_go_migration/contract_test.go | 241 ++++++++++++++++ ACM-42568_go_migration/go.mod | 8 + ACM-42568_go_migration/go.sum | 6 + ACM-42568_go_migration/normalize.go | 127 ++++++++ ACM-42568_go_migration/sse.go | 272 ++++++++++++++++++ ACM-42568_go_migration/ws.go | 109 +++++++ 24 files changed, 2752 insertions(+) create mode 100644 ACM-42568_go_migration/.gitignore create mode 100644 ACM-42568_go_migration/QUIRKS.md create mode 100644 ACM-42568_go_migration/README.md create mode 100644 ACM-42568_go_migration/assert.go create mode 100644 ACM-42568_go_migration/catalog.go create mode 100644 ACM-42568_go_migration/catalog/00-probes.yaml create mode 100644 ACM-42568_go_migration/catalog/10-kube-proxy.yaml create mode 100644 ACM-42568_go_migration/catalog/20-metrics-mcp-vm-serve.yaml create mode 100644 ACM-42568_go_migration/catalog/30-auth-cluster.yaml create mode 100644 ACM-42568_go_migration/catalog/40-aggregate-search.yaml create mode 100644 ACM-42568_go_migration/catalog/50-sse.yaml create mode 100644 ACM-42568_go_migration/catalog/60-websocket.yaml create mode 100644 ACM-42568_go_migration/catalog/70-long-tail.yaml create mode 100644 ACM-42568_go_migration/catalog/80-negative.yaml create mode 100644 ACM-42568_go_migration/catalog/watched-resources.yaml create mode 100644 ACM-42568_go_migration/client.go create mode 100644 ACM-42568_go_migration/compare.go create mode 100644 ACM-42568_go_migration/config.go create mode 100644 ACM-42568_go_migration/contract_test.go create mode 100644 ACM-42568_go_migration/go.mod create mode 100644 ACM-42568_go_migration/go.sum create mode 100644 ACM-42568_go_migration/normalize.go create mode 100644 ACM-42568_go_migration/sse.go create mode 100644 ACM-42568_go_migration/ws.go 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..514cf71700 --- /dev/null +++ b/ACM-42568_go_migration/QUIRKS.md @@ -0,0 +1,112 @@ +# 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`. + +**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). + +## Not in Node (do not require against upstream/main) + +Routes added only on the Go skeleton (for example `/events/rbac`) are out of this catalog until Node never served them. diff --git a/ACM-42568_go_migration/README.md b/ACM-42568_go_migration/README.md new file mode 100644 index 0000000000..3bc3a7800d --- /dev/null +++ b/ACM-42568_go_migration/README.md @@ -0,0 +1,64 @@ +# Contract test framework (ACM-42590) + +Isolated Go module. It does **not** import console backend or frontend code. It is a black-box HTTP client that records the Node backend contract so the same tests can later target Go. + +This folder is not wired into the monorepo `package.json` or CI. A later PR can keep it in-tree as an archive until the suite is adopted as a migration gate. + +## Prerequisites + +- Hub cluster `oc login` +- Console backend running (`npm run plugins` from repo root; Node listens on `https://localhost:4000`) +- Go 1.22+ + +## Run against Node (local plugins) + +```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 +``` + +If `CONTRACT_TOKEN` is unset, tests try `oc whoami -t`. If `/ping` is unreachable, live tests are skipped; catalog/parser unit tests still run. + +### Through the OpenShift Console plugin proxy + +```bash +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 +``` + +## Modes + +| Env | Effect | +|-----|--------| +| `CONTRACT_MODE=assert` (default) | Check status, headers, JSON shape, SSE framing, WS upgrade | +| `CONTRACT_COMPARE_URL=https://...` | Replay each REST case against a second backend and diff normalized headers/bodies | +| `CONTRACT_RECORD=1` | Write normalized captures under `testdata/recorded/` (gitignored) | + +Other knobs: `CONTRACT_SSE_TIMEOUT` (seconds, default 120), `CONTRACT_HTTP_TIMEOUT` (default 60), `CONTRACT_TLS_INSECURE` (default true), `CONTRACT_RECORD_DIR`. + +## Catalog + +YAML under `catalog/` is the inventory of frontend↔backend contracts from `backend/src/app.ts` and the plugin. Adding a route is a new YAML case; the runner does not change. + +- `soft: true` + `softStatuses` — skip (not fail) when an optional upstream is missing (Observability, ROSA/OCM, Search) +- `alsoMulticloud: true` — repeat the case with `/multicloud` prefix stripping +- `kind: sse` / `kind: websocket` — stream and upgrade cases +- `auth: invalid` — fake Bearer; use this for 401 cases (bare `auth: none` is not unauthenticated in Node development) + +See `QUIRKS.md` for Node behaviors that Go should replicate on purpose. + +## Layout + +```text +ACM-42568_go_migration/ +├── catalog/ # declarative cases +├── testdata/ # optional recordings (gitignored) +├── *.go # client, SSE/WS, asserts, tests +├── 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..b5f523055b --- /dev/null +++ b/ACM-42568_go_migration/catalog.go @@ -0,0 +1,126 @@ +/* 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"` +} + +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.ForwardEventsToClients != nil && !*r.ForwardEventsToClients { + continue + } + out[r.Kind] = struct{}{} + } + 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..7222ade0e8 --- /dev/null +++ b/ACM-42568_go_migration/catalog/10-kube-proxy.yaml @@ -0,0 +1,196 @@ +# 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 + 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 + 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..723ec9fc76 --- /dev/null +++ b/ACM-42568_go_migration/catalog/40-aggregate-search.yaml @@ -0,0 +1,72 @@ +# Copyright Contributors to the Open Cluster Management project +cases: + - id: aggregate-statuses + group: aggregate + description: POST /aggregate/statuses application dashboard counts + method: POST + path: /aggregate/statuses + auth: bearer + alsoMulticloud: true + contentType: application/json + timeoutSeconds: 120 + body: + clusters: [] + expect: + status: [200] + contentTypeContains: json + jsonKeys: [itemCount, filterCounts, systemAppNSPrefixes, loading] + + - id: aggregate-applications + group: aggregate + description: POST /aggregate/applications paginated list + method: POST + path: /aggregate/applications + auth: bearer + alsoMulticloud: true + contentType: application/json + timeoutSeconds: 120 + body: + page: 1 + perPage: 10 + expect: + status: [200] + contentTypeContains: json + jsonKeys: [page, items, processedItemCount, emptyResult, isPreProcessed, request] + + - id: aggregate-unknown + group: aggregate + description: POST /aggregate/not-a-type is 404 + method: POST + path: /aggregate/not-a-type + auth: bearer + contentType: application/json + body: {} + expect: + status: [404] + bodyEmpty: true + + - id: search-graphql + group: search + description: POST /proxy/search GraphQL search query + 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..c0051e0441 --- /dev/null +++ b/ACM-42568_go_migration/catalog/50-sse.yaml @@ -0,0 +1,31 @@ +# 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 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..191abf2e95 --- /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 (Node only registers the prefixed upgrade) + 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..cfad5c99fa --- /dev/null +++ b/ACM-42568_go_migration/catalog/70-long-tail.yaml @@ -0,0 +1,232 @@ +# Copyright Contributors to the Open Cluster Management project +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..c18dfcb189 --- /dev/null +++ b/ACM-42568_go_migration/catalog/watched-resources.yaml @@ -0,0 +1,127 @@ +# Copyright Contributors to the Open Cluster Management project +# Kinds watched by backend/src/routes/events.ts and streamed on GET /events. +# Authentication is watched but not forwarded (forwardEventsToClients: false). +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 + - 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 + - kind: Namespace + apiVersion: v1 + - kind: Secret + apiVersion: v1 + - 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: Search + apiVersion: search.open-cluster-management.io/v1alpha1 + - kind: MulticlusterRoleAssignment + apiVersion: rbac.open-cluster-management.io/v1beta1 + - kind: User + apiVersion: user.openshift.io/v1 + - kind: Group + apiVersion: user.openshift.io/v1 + - kind: ClusterRole + apiVersion: rbac.authorization.k8s.io/v1 + - kind: Service + apiVersion: v1 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.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..ea7db923aa --- /dev/null +++ b/ACM-42568_go_migration/config.go @@ -0,0 +1,139 @@ +/* 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, + 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..23fa5b997f --- /dev/null +++ b/ACM-42568_go_migration/contract_test.go @@ -0,0 +1,241 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +package contract + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +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) + } + watched := WatchedKindSet(resources) + t.Logf("backend=%s cases=%d mode=%s", cfg.BackendURL, len(cases), cfg.Mode) + + 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) { + runCase(t, cfg, cs, p, watched) + }) + } + } +} + +func runCase(t *testing.T, cfg Config, cs Case, path string, watched map[string]struct{}) { + 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, wrapSoft(cs, err)) + return + } + if err := AssertCapture(cs, cap, watched); err != nil { + failOrSkip(t, err) + return + } + recordAndCompare(t, cfg, cs, path, cap) + case "websocket": + if err := cfg.RunWebSocket(cfg.BackendURL, cs, path); err != nil { + failOrSkip(t, err) + return + } + if cfg.CompareURL != "" { + if err := cfg.RunWebSocket(cfg.CompareURL, cs, path); err != nil { + failOrSkip(t, err) + } + } + default: + cap, err := cfg.Do(cfg.NewHTTPClient(timeoutFor(cfg, cs)), cfg.BackendURL, cs, path) + if err != nil { + failOrSkip(t, wrapSoft(cs, err)) + return + } + if err := AssertCapture(cs, cap, watched); err != nil { + failOrSkip(t, err) + return + } + recordAndCompare(t, cfg, cs, path, cap) + } +} + +func recordAndCompare(t *testing.T, cfg Config, cs Case, path string, cap Capture) { + 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 { + t.Fatalf("record: %v", err) + } + } + if cfg.CompareURL == "" || strings.EqualFold(cs.Kind, "sse") || strings.EqualFold(cs.Kind, "websocket") { + return + } + other, err := cfg.Do(cfg.NewHTTPClient(timeoutFor(cfg, cs)), cfg.CompareURL, cs, path) + if err != nil { + t.Fatalf("compare request: %v", err) + } + if err := DiffCaptures(cap, other); err != nil { + t.Fatalf("compare %s: %v", cfg.CompareURL, err) + } +} + +func failOrSkip(t *testing.T, err error) { + t.Helper() + if IsSoftSkip(err) { + t.Skip(err.Error()) + return + } + 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) < 50 { + 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/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/sse.go b/ACM-42568_go_migration/sse.go new file mode 100644 index 0000000000..d80a9326f0 --- /dev/null +++ b/ACM-42568_go_migration/sse.go @@ -0,0 +1,272 @@ +/* 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() + + 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 + } +} From b698b3f0e9498f168e1eef1704c10e8c4625503c Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Tue, 1 Sep 2026 10:36:21 +0200 Subject: [PATCH 2/9] run-catalog.sh Signed-off-by: Enrique Mingorance Cano --- ACM-42568_go_migration/README.md | 65 ++++++---- ACM-42568_go_migration/contract_test.go | 55 +++++--- ACM-42568_go_migration/report.go | 161 ++++++++++++++++++++++++ ACM-42568_go_migration/report_test.go | 40 ++++++ ACM-42568_go_migration/run-catalog.sh | 21 ++++ 5 files changed, 304 insertions(+), 38 deletions(-) create mode 100644 ACM-42568_go_migration/report.go create mode 100644 ACM-42568_go_migration/report_test.go create mode 100755 ACM-42568_go_migration/run-catalog.sh diff --git a/ACM-42568_go_migration/README.md b/ACM-42568_go_migration/README.md index 3bc3a7800d..7072fe20f7 100644 --- a/ACM-42568_go_migration/README.md +++ b/ACM-42568_go_migration/README.md @@ -1,63 +1,82 @@ # Contract test framework (ACM-42590) -Isolated Go module. It does **not** import console backend or frontend code. It is a black-box HTTP client that records the Node backend contract so the same tests can later target Go. +Isolated Go module. Black-box HTTP client against the running console backend (Node today, Go later). Not wired into root `package.json` or CI. -This folder is not wired into the monorepo `package.json` or CI. A later PR can keep it in-tree as an archive until the suite is adopted as a migration gate. +## Quick start (npm run plugins already running) -## Prerequisites +With `npm run plugins` up and `oc login` done: -- Hub cluster `oc login` -- Console backend running (`npm run plugins` from repo root; Node listens on `https://localhost:4000`) -- Go 1.22+ +```bash +cd ACM-42568_go_migration +./run-catalog.sh +``` -## Run against Node (local plugins) +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 +go test . -count=1 -timeout 15m -v -run TestCatalogAgainstBackend ``` -If `CONTRACT_TOKEN` is unset, tests try `oc whoami -t`. If `/ping` is unreachable, live tests are skipped; catalog/parser unit tests still run. +**You only need the Node 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`. -### Through the OpenShift Console plugin proxy +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` shows the summary without every subtest line. Use `go test -v` for per-case detail. `NO_COLOR=1` disables colors. + +## Prerequisites + +- Hub: `oc login` +- Backend: `npm run plugins` from repo root (Node on `https://localhost:4000`) +- Go 1.22+ + +## 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 +go test . -count=1 -timeout 15m ``` +For day-to-day contract runs against Node, 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 each REST case against a second backend and diff normalized headers/bodies | -| `CONTRACT_RECORD=1` | Write normalized captures under `testdata/recorded/` (gitignored) | +| `CONTRACT_COMPARE_URL=https://...` | Replay REST cases against a second backend and diff | +| `CONTRACT_RECORD=1` | Write captures under `testdata/recorded/` (gitignored) | -Other knobs: `CONTRACT_SSE_TIMEOUT` (seconds, default 120), `CONTRACT_HTTP_TIMEOUT` (default 60), `CONTRACT_TLS_INSECURE` (default true), `CONTRACT_RECORD_DIR`. +Other: `CONTRACT_SSE_TIMEOUT` (default 120s), `CONTRACT_HTTP_TIMEOUT` (default 60s), `CONTRACT_TLS_INSECURE` (default true), `CONTRACT_RECORD_DIR`. ## Catalog -YAML under `catalog/` is the inventory of frontend↔backend contracts from `backend/src/app.ts` and the plugin. Adding a route is a new YAML case; the runner does not change. +YAML in `catalog/` — add a route = add a YAML case. -- `soft: true` + `softStatuses` — skip (not fail) when an optional upstream is missing (Observability, ROSA/OCM, Search) -- `alsoMulticloud: true` — repeat the case with `/multicloud` prefix stripping -- `kind: sse` / `kind: websocket` — stream and upgrade cases -- `auth: invalid` — fake Bearer; use this for 401 cases (bare `auth: none` is not unauthenticated in Node development) +- `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 -See `QUIRKS.md` for Node behaviors that Go should replicate on purpose. +See `QUIRKS.md` for Node behaviors Go should replicate. ## Layout ```text ACM-42568_go_migration/ -├── catalog/ # declarative cases -├── testdata/ # optional recordings (gitignored) -├── *.go # client, SSE/WS, asserts, tests +├── catalog/ +├── run-catalog.sh # full catalog + colored summary +├── testdata/recorded/ # gitignored +├── *.go ├── go.mod ├── README.md └── QUIRKS.md diff --git a/ACM-42568_go_migration/contract_test.go b/ACM-42568_go_migration/contract_test.go index 23fa5b997f..ad046c97c1 100644 --- a/ACM-42568_go_migration/contract_test.go +++ b/ACM-42568_go_migration/contract_test.go @@ -13,6 +13,12 @@ import ( "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) @@ -41,6 +47,8 @@ func TestCatalogAgainstBackend(t *testing.T) { 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} @@ -54,51 +62,54 @@ func TestCatalogAgainstBackend(t *testing.T) { name += "/multicloud" } t.Run(name, func(t *testing.T) { - runCase(t, cfg, cs, p, watched) + runCase(t, cfg, cs, p, watched, name) }) } } } -func runCase(t *testing.T, cfg Config, cs Case, path string, watched map[string]struct{}) { +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, wrapSoft(cs, err)) + failOrSkip(t, reportName, wrapSoft(cs, err)) return } if err := AssertCapture(cs, cap, watched); err != nil { - failOrSkip(t, err) + failOrSkip(t, reportName, wrapSoft(cs, err)) return } - recordAndCompare(t, cfg, cs, path, cap) + recordAndCompare(t, cfg, cs, path, cap, reportName) case "websocket": if err := cfg.RunWebSocket(cfg.BackendURL, cs, path); err != nil { - failOrSkip(t, err) + failOrSkip(t, reportName, wrapSoft(cs, err)) return } if cfg.CompareURL != "" { if err := cfg.RunWebSocket(cfg.CompareURL, cs, path); err != nil { - failOrSkip(t, err) + 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, wrapSoft(cs, err)) + failOrSkip(t, reportName, wrapSoft(cs, err)) return } if err := AssertCapture(cs, cap, watched); err != nil { - failOrSkip(t, err) + failOrSkip(t, reportName, wrapSoft(cs, err)) return } - recordAndCompare(t, cfg, cs, path, cap) + recordAndCompare(t, cfg, cs, path, cap, reportName) } + catalogSummary.record(reportName, resultPass) } -func recordAndCompare(t *testing.T, cfg Config, cs Case, path string, cap Capture) { +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{ @@ -109,7 +120,7 @@ func recordAndCompare(t *testing.T, cfg Config, cs Case, path string, cap Captur Body: NormalizeBody(cap.Decoded), } if err := WriteRecord(cfg.RecordDir, rec); err != nil { - t.Fatalf("record: %v", err) + failHard(t, reportName, fmt.Errorf("record: %w", err)) } } if cfg.CompareURL == "" || strings.EqualFold(cs.Kind, "sse") || strings.EqualFold(cs.Kind, "websocket") { @@ -117,19 +128,33 @@ func recordAndCompare(t *testing.T, cfg Config, cs Case, path string, cap Captur } other, err := cfg.Do(cfg.NewHTTPClient(timeoutFor(cfg, cs)), cfg.CompareURL, cs, path) if err != nil { - t.Fatalf("compare request: %v", err) + failHard(t, reportName, fmt.Errorf("compare request: %w", err)) + return } if err := DiffCaptures(cap, other); err != nil { - t.Fatalf("compare %s: %v", cfg.CompareURL, err) + failHard(t, reportName, fmt.Errorf("compare %s: %w", cfg.CompareURL, err)) } } -func failOrSkip(t *testing.T, err error) { +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) } 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..78a28ca8f4 --- /dev/null +++ b/ACM-42568_go_migration/run-catalog.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Copyright Contributors to the Open Cluster Management project +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 + +TIMEOUT="${CONTRACT_TIMEOUT:-15m}" +BIN="${TMPDIR:-/tmp}/acm-42590-contract.test" + +echo "Backend: $CONTRACT_BACKEND_URL" +echo "Building test binary..." +go test -c -o "$BIN" . + +echo "Running full catalog (TestCatalogAgainstBackend)..." +"$BIN" -test.timeout="$TIMEOUT" -test.run=TestCatalogAgainstBackend From 46a5b4b250ffe499661f53e486a695fd9c25ff26 Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Wed, 2 Sep 2026 10:05:38 +0200 Subject: [PATCH 3/9] Add contract catalog preflight for hub alignment and TLS. Fail fast on CLUSTER_API_URL mismatch, missing certs, or a dead /ping so catalog FAILs are not false negatives. Signed-off-by: Enrique Mingorance Cano Co-authored-by: Cursor --- ACM-42568_go_migration/run-catalog.sh | 71 +++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/ACM-42568_go_migration/run-catalog.sh b/ACM-42568_go_migration/run-catalog.sh index 78a28ca8f4..4547ece694 100755 --- a/ACM-42568_go_migration/run-catalog.sh +++ b/ACM-42568_go_migration/run-catalog.sh @@ -3,6 +3,8 @@ 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 @@ -13,7 +15,76 @@ fi TIMEOUT="${CONTRACT_TIMEOUT:-15m}" 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 +} + echo "Backend: $CONTRACT_BACKEND_URL" +preflight + echo "Building test binary..." go test -c -o "$BIN" . From bb695d0443f62ad23c2bb29e5f05d3825375f69e Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Wed, 2 Sep 2026 10:05:46 +0200 Subject: [PATCH 4/9] Complete watched-resources.yaml with selectors matching events.ts. Each of the 67 definitions keeps labelSelector, fieldSelector, polled, and forwardEventsToClients. A unit test diffs the YAML against backend-node definitions. Signed-off-by: Enrique Mingorance Cano Co-authored-by: Cursor --- ACM-42568_go_migration/catalog.go | 39 +++- .../catalog/watched-resources.yaml | 54 ++++- ACM-42568_go_migration/events_defs_test.go | 203 ++++++++++++++++++ 3 files changed, 287 insertions(+), 9 deletions(-) create mode 100644 ACM-42568_go_migration/events_defs_test.go diff --git a/ACM-42568_go_migration/catalog.go b/ACM-42568_go_migration/catalog.go index b5f523055b..509b82fa6f 100644 --- a/ACM-42568_go_migration/catalog.go +++ b/ACM-42568_go_migration/catalog.go @@ -17,10 +17,29 @@ type CatalogFile struct { } type Resource struct { - Kind string `yaml:"kind"` - APIVersion string `yaml:"apiVersion"` - Polled bool `yaml:"polled"` - ForwardEventsToClients *bool `yaml:"forwardEventsToClients"` + 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 { @@ -117,10 +136,20 @@ func LoadCatalog(dir string) ([]Case, []Resource, error) { func WatchedKindSet(resources []Resource) map[string]struct{} { out := make(map[string]struct{}) for _, r := range resources { - if r.ForwardEventsToClients != nil && !*r.ForwardEventsToClients { + 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/watched-resources.yaml b/ACM-42568_go_migration/catalog/watched-resources.yaml index c18dfcb189..010211712d 100644 --- a/ACM-42568_go_migration/catalog/watched-resources.yaml +++ b/ACM-42568_go_migration/catalog/watched-resources.yaml @@ -1,6 +1,7 @@ # Copyright Contributors to the Open Cluster Management project -# Kinds watched by backend/src/routes/events.ts and streamed on GET /events. -# Authentication is watched but not forwarded (forwardEventsToClients: false). +# 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 @@ -41,6 +42,8 @@ resources: 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 @@ -101,10 +104,35 @@ resources: 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 @@ -113,15 +141,33 @@ resources: 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: ClusterRole - apiVersion: rbac.authorization.k8s.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/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 +} From 2ae7302c880b0f2ad1e6e401bc393f5b08d519a7 Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Wed, 2 Sep 2026 10:05:54 +0200 Subject: [PATCH 5/9] Add informer cache snapshot harness for ACM-42597. Normalize {apiVersion,kind,namespace,name}, exclude polled kinds, and skip when GET /debug/informer-snapshot is not wired yet. REST compare stays SSE-free until ACM-42598. Signed-off-by: Enrique Mingorance Cano Co-authored-by: Cursor --- .../compare-informer-cache.sh | 34 +++ ACM-42568_go_migration/contract_test.go | 5 +- ACM-42568_go_migration/snapshot.go | 165 +++++++++++++++ ACM-42568_go_migration/snapshot_test.go | 194 ++++++++++++++++++ 4 files changed, 396 insertions(+), 2 deletions(-) create mode 100755 ACM-42568_go_migration/compare-informer-cache.sh create mode 100644 ACM-42568_go_migration/snapshot.go create mode 100644 ACM-42568_go_migration/snapshot_test.go 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..ba3916363f --- /dev/null +++ b/ACM-42568_go_migration/compare-informer-cache.sh @@ -0,0 +1,34 @@ +#!/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). +# Skips (exit 0) when GET /debug/informer-snapshot is 404 — Go cache not wired yet. +# +# 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/contract_test.go b/ACM-42568_go_migration/contract_test.go index ad046c97c1..1d3a8dc3a2 100644 --- a/ACM-42568_go_migration/contract_test.go +++ b/ACM-42568_go_migration/contract_test.go @@ -123,7 +123,8 @@ func recordAndCompare(t *testing.T, cfg Config, cs Case, path string, cap Captur failHard(t, reportName, fmt.Errorf("record: %w", err)) } } - if cfg.CompareURL == "" || strings.EqualFold(cs.Kind, "sse") || strings.EqualFold(cs.Kind, "websocket") { + 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) @@ -198,7 +199,7 @@ func TestLoadCatalog(t *testing.T) { if len(cases) < 40 { t.Fatalf("expected a full catalog, got %d cases", len(cases)) } - if len(resources) < 50 { + if len(resources) < 67 { t.Fatalf("expected watched resources, got %d", len(resources)) } seen := map[string]struct{}{} 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()) + } +} From 4c8dfe8958e56702cca18a1dd9c377d34c0fe1a4 Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Wed, 2 Sep 2026 10:06:02 +0200 Subject: [PATCH 6/9] Document catalog layers and add GET /events/rbac SSE case. Clarify REST vs SSE compare limits, plugin hub/TLS failures, and the ACM-42597 snapshot gate. events-rbac-sse is soft so sidecar-only runs still skip 404. Signed-off-by: Enrique Mingorance Cano Co-authored-by: Cursor --- ACM-42568_go_migration/QUIRKS.md | 22 +++++++-- ACM-42568_go_migration/README.md | 57 +++++++++++++++++++--- ACM-42568_go_migration/catalog/50-sse.yaml | 23 +++++++++ 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/ACM-42568_go_migration/QUIRKS.md b/ACM-42568_go_migration/QUIRKS.md index 514cf71700..23d5a7cc99 100644 --- a/ACM-42568_go_migration/QUIRKS.md +++ b/ACM-42568_go_migration/QUIRKS.md @@ -60,7 +60,7 @@ TokenReview username is lowercased and non `[a-z0-9-.]` characters become `-` fo ### Authentication CR is not fanned out on SSE -`{ kind: 'Authentication', apiVersion: 'config.openshift.io/v1', forwardEventsToClients: false }`. Hub still reads it for `/hub`. +`{ 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. @@ -107,6 +107,22 @@ Long Kubernetes names need this; unmatched routes 404. - 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). -## Not in Node (do not require against upstream/main) +## Plugin proxy `authorize: true` and hub alignment -Routes added only on the Go skeleton (for example `/events/rbac`) are out of this catalog until Node never served them. +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 index 7072fe20f7..f61bbd20be 100644 --- a/ACM-42568_go_migration/README.md +++ b/ACM-42568_go_migration/README.md @@ -20,10 +20,12 @@ export CONTRACT_TOKEN=$(oc whoami -t) go test . -count=1 -timeout 15m -v -run TestCatalogAgainstBackend ``` -**You only need the Node backend on :4000** (started by `npm run plugins`). You do **not** need OpenShift Console on :9000. +**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` shows the summary without every subtest line. Use `go test -v` for per-case detail. `NO_COLOR=1` disables colors. @@ -31,8 +33,48 @@ At the end: colored summary — **Executed**, **OK** (green), **SOFT** (yellow), ## Prerequisites - Hub: `oc login` -- Backend: `npm run plugins` from repo root (Node on `https://localhost:4000`) +- 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. + +### `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: + +| 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 @@ -46,14 +88,14 @@ export CONTRACT_TOKEN=$(oc whoami -t) go test . -count=1 -timeout 15m ``` -For day-to-day contract runs against Node, use **:4000** (quick start above). +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 | +| `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`. @@ -67,6 +109,8 @@ YAML in `catalog/` — add a route = add a YAML case. - `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 @@ -74,8 +118,9 @@ See `QUIRKS.md` for Node behaviors Go should replicate. ```text ACM-42568_go_migration/ ├── catalog/ -├── run-catalog.sh # full catalog + colored summary -├── testdata/recorded/ # gitignored +├── run-catalog.sh # preflight + full catalog + colored summary +├── compare-informer-cache.sh # ACM-42597 snapshot gate +├── testdata/recorded/ # gitignored ├── *.go ├── go.mod ├── README.md diff --git a/ACM-42568_go_migration/catalog/50-sse.yaml b/ACM-42568_go_migration/catalog/50-sse.yaml index c0051e0441..5332d5c1b1 100644 --- a/ACM-42568_go_migration/catalog/50-sse.yaml +++ b/ACM-42568_go_migration/catalog/50-sse.yaml @@ -29,3 +29,26 @@ cases: - 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 From 53858879fbeb711658703ea767a47ffa5589d8f3 Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Wed, 2 Sep 2026 12:40:54 +0200 Subject: [PATCH 7/9] updates (#56) --- ACM-42568_go_migration/README.md | 2 ++ ACM-42568_go_migration/compare-informer-cache.sh | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/ACM-42568_go_migration/README.md b/ACM-42568_go_migration/README.md index f61bbd20be..15fb877982 100644 --- a/ACM-42568_go_migration/README.md +++ b/ACM-42568_go_migration/README.md @@ -68,6 +68,8 @@ cd ACM-42568_go_migration 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` | diff --git a/ACM-42568_go_migration/compare-informer-cache.sh b/ACM-42568_go_migration/compare-informer-cache.sh index ba3916363f..ee4402382e 100755 --- a/ACM-42568_go_migration/compare-informer-cache.sh +++ b/ACM-42568_go_migration/compare-informer-cache.sh @@ -2,7 +2,8 @@ # Copyright Contributors to the Open Cluster Management project # # ACM-42597 snapshot gate: compare Go informer cache vs Node (or a file dump). -# Skips (exit 0) when GET /debug/informer-snapshot is 404 — Go cache not wired yet. +# 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 From 6f5f233ab2f03e6f4e85cb7fe7518d8ef3ed2854 Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Thu, 10 Sep 2026 15:06:06 +0200 Subject: [PATCH 8/9] 40-aggregate-search.yaml ACM-42600 Signed-off-by: Enrique Mingorance Cano --- .../catalog/40-aggregate-search.yaml | 118 ++++++++++++++++-- 1 file changed, 108 insertions(+), 10 deletions(-) diff --git a/ACM-42568_go_migration/catalog/40-aggregate-search.yaml b/ACM-42568_go_migration/catalog/40-aggregate-search.yaml index 723ec9fc76..4e38bd327f 100644 --- a/ACM-42568_go_migration/catalog/40-aggregate-search.yaml +++ b/ACM-42568_go_migration/catalog/40-aggregate-search.yaml @@ -1,24 +1,39 @@ # 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-statuses + - id: aggregate-applications-page group: aggregate - description: POST /aggregate/statuses application dashboard counts + description: POST /aggregate/applications paginated list method: POST - path: /aggregate/statuses + path: /aggregate/applications auth: bearer alsoMulticloud: true contentType: application/json timeoutSeconds: 120 body: - clusters: [] + page: 1 + perPage: 10 + sortBy: + index: 0 + direction: asc expect: status: [200] contentTypeContains: json - jsonKeys: [itemCount, filterCounts, systemAppNSPrefixes, loading] + jsonKeys: + - page + - items + - processedItemCount + - emptyResult + - isPreProcessed + - request - - id: aggregate-applications + - id: aggregate-applications-filter-search group: aggregate - description: POST /aggregate/applications paginated list + description: POST /aggregate/applications with filter, search, and sort method: POST path: /aggregate/applications auth: bearer @@ -28,17 +43,100 @@ cases: body: page: 1 perPage: 10 + search: tes + filters: + type: [subscription] + sortBy: + index: 0 + direction: desc expect: status: [200] contentTypeContains: json - jsonKeys: [page, items, processedItemCount, emptyResult, isPreProcessed, request] + + - 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: 120 + 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: 120 + 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: 120 + 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 + 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: @@ -47,7 +145,7 @@ cases: - id: search-graphql group: search - description: POST /proxy/search GraphQL search query + description: POST /proxy/search GraphQL search query (Node sidecar) method: POST path: /proxy/search auth: bearer From b2baf6ea66ba697bf6521464c095399b15a46b61 Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Fri, 11 Sep 2026 08:53:24 +0200 Subject: [PATCH 9/9] tests updated and improved Signed-off-by: Enrique Mingorance Cano --- ACM-42568_go_migration/README.md | 18 ++- .../catalog/10-kube-proxy.yaml | 4 + .../catalog/40-aggregate-search.yaml | 121 ++++++++++++++++-- .../catalog/60-websocket.yaml | 2 +- .../catalog/70-long-tail.yaml | 3 +- .../compare-informer-cache.sh | 3 +- ACM-42568_go_migration/config.go | 3 +- ACM-42568_go_migration/contract_test.go | 13 ++ ACM-42568_go_migration/run-catalog.sh | 49 ++++++- ACM-42568_go_migration/sse.go | 5 + 10 files changed, 202 insertions(+), 19 deletions(-) diff --git a/ACM-42568_go_migration/README.md b/ACM-42568_go_migration/README.md index f61bbd20be..90c167cf0b 100644 --- a/ACM-42568_go_migration/README.md +++ b/ACM-42568_go_migration/README.md @@ -28,7 +28,7 @@ Quick check: `curl -sk https://localhost:4000/ping` → `200`. 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` shows the summary without every subtest line. Use `go test -v` for per-case detail. `NO_COLOR=1` disables colors. +`./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 @@ -43,6 +43,20 @@ At the end: colored summary — **Executed**, **OK** (green), **SOFT** (yellow), `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). @@ -68,6 +82,8 @@ cd ACM-42568_go_migration 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` | diff --git a/ACM-42568_go_migration/catalog/10-kube-proxy.yaml b/ACM-42568_go_migration/catalog/10-kube-proxy.yaml index 7222ade0e8..3a9b00f852 100644 --- a/ACM-42568_go_migration/catalog/10-kube-proxy.yaml +++ b/ACM-42568_go_migration/catalog/10-kube-proxy.yaml @@ -24,6 +24,8 @@ cases: path: /apis auth: bearer alsoMulticloud: true + soft: true + timeoutSeconds: 60 headers: Accept: application/json expect: @@ -64,6 +66,8 @@ cases: method: GET path: /api/v1/namespaces auth: bearer + soft: true + timeoutSeconds: 60 headers: Accept: application/json expect: diff --git a/ACM-42568_go_migration/catalog/40-aggregate-search.yaml b/ACM-42568_go_migration/catalog/40-aggregate-search.yaml index 723ec9fc76..cea7ed1e74 100644 --- a/ACM-42568_go_migration/catalog/40-aggregate-search.yaml +++ b/ACM-42568_go_migration/catalog/40-aggregate-search.yaml @@ -1,44 +1,141 @@ # Copyright Contributors to the Open Cluster Management project +# +# POST /aggregate/{applications,statuses,appSetData} — Go when CONSOLE_INFORMER_CACHE is on (ACM-42600). +# POST /proxy/search and Search graphql-ws — Go (ACM-42601). + cases: - - id: aggregate-statuses + - id: aggregate-applications-page group: aggregate - description: POST /aggregate/statuses application dashboard counts + description: POST /aggregate/applications paginated list method: POST - path: /aggregate/statuses + path: /aggregate/applications auth: bearer alsoMulticloud: true contentType: application/json - timeoutSeconds: 120 + timeoutSeconds: 60 body: - clusters: [] + page: 1 + perPage: 10 + sortBy: + index: 0 + direction: asc expect: status: [200] contentTypeContains: json - jsonKeys: [itemCount, filterCounts, systemAppNSPrefixes, loading] + jsonKeys: + - page + - items + - processedItemCount + - emptyResult + - isPreProcessed + - request - - id: aggregate-applications + - id: aggregate-applications-filter-search group: aggregate - description: POST /aggregate/applications paginated list + description: POST /aggregate/applications with filter, search, and sort method: POST path: /aggregate/applications auth: bearer alsoMulticloud: true contentType: application/json - timeoutSeconds: 120 + 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: [page, items, processedItemCount, emptyResult, isPreProcessed, request] + 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 + 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: @@ -47,7 +144,7 @@ cases: - id: search-graphql group: search - description: POST /proxy/search GraphQL search query + description: POST /proxy/search GraphQL search query (Go searchproxy) method: POST path: /proxy/search auth: bearer diff --git a/ACM-42568_go_migration/catalog/60-websocket.yaml b/ACM-42568_go_migration/catalog/60-websocket.yaml index 191abf2e95..f47c87a282 100644 --- a/ACM-42568_go_migration/catalog/60-websocket.yaml +++ b/ACM-42568_go_migration/catalog/60-websocket.yaml @@ -3,7 +3,7 @@ cases: - id: search-websocket group: websocket kind: websocket - description: WebSocket upgrade /multicloud/proxy/search (Node only registers the prefixed upgrade) + description: WebSocket upgrade /multicloud/proxy/search (Go searchproxy graphql-ws relay) method: GET path: /multicloud/proxy/search auth: bearer diff --git a/ACM-42568_go_migration/catalog/70-long-tail.yaml b/ACM-42568_go_migration/catalog/70-long-tail.yaml index cfad5c99fa..d63ccb5106 100644 --- a/ACM-42568_go_migration/catalog/70-long-tail.yaml +++ b/ACM-42568_go_migration/catalog/70-long-tail.yaml @@ -1,4 +1,5 @@ -# Copyright Contributors to the Open Cluster Management project +# 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 diff --git a/ACM-42568_go_migration/compare-informer-cache.sh b/ACM-42568_go_migration/compare-informer-cache.sh index ba3916363f..ee4402382e 100755 --- a/ACM-42568_go_migration/compare-informer-cache.sh +++ b/ACM-42568_go_migration/compare-informer-cache.sh @@ -2,7 +2,8 @@ # Copyright Contributors to the Open Cluster Management project # # ACM-42597 snapshot gate: compare Go informer cache vs Node (or a file dump). -# Skips (exit 0) when GET /debug/informer-snapshot is 404 — Go cache not wired yet. +# 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 diff --git a/ACM-42568_go_migration/config.go b/ACM-42568_go_migration/config.go index ea7db923aa..4b35f62ac1 100644 --- a/ACM-42568_go_migration/config.go +++ b/ACM-42568_go_migration/config.go @@ -82,7 +82,8 @@ func (c Config) NewHTTPClient(timeout time.Duration) *http.Client { timeout = c.HTTPTimeout } transport := &http.Transport{ - Proxy: http.ProxyFromEnvironment, + 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, diff --git a/ACM-42568_go_migration/contract_test.go b/ACM-42568_go_migration/contract_test.go index 1d3a8dc3a2..882484857d 100644 --- a/ACM-42568_go_migration/contract_test.go +++ b/ACM-42568_go_migration/contract_test.go @@ -44,6 +44,18 @@ func TestCatalogAgainstBackend(t *testing.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) @@ -62,6 +74,7 @@ func TestCatalogAgainstBackend(t *testing.T) { 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) }) } diff --git a/ACM-42568_go_migration/run-catalog.sh b/ACM-42568_go_migration/run-catalog.sh index 4547ece694..52036eb9c9 100755 --- a/ACM-42568_go_migration/run-catalog.sh +++ b/ACM-42568_go_migration/run-catalog.sh @@ -12,7 +12,11 @@ if [[ -z "${CONTRACT_TOKEN:-}" ]]; then export CONTRACT_TOKEN fi -TIMEOUT="${CONTRACT_TIMEOUT:-15m}" +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() { @@ -66,6 +70,8 @@ EOF ping_url="${CONTRACT_BACKEND_URL%/}/ping" http_code="$(curl -sk -o /dev/null -w '%{http_code}' --max-time 8 "$ping_url" || true)" + static_url="${CONTRACT_BACKEND_URL%/}/acm-42590-definitely-missing.json" + static_code="$(curl -sk -o /dev/null -w '%{http_code}' --max-time 8 "$static_url" || true)" if [[ "$http_code" != "200" ]]; then cat >&2 < ${http_code:-curl-failed}) @@ -77,6 +83,32 @@ Start npm run plugins from the repo root. You only need the backend on :4000 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 @@ -89,4 +121,17 @@ echo "Building test binary..." go test -c -o "$BIN" . echo "Running full catalog (TestCatalogAgainstBackend)..." -"$BIN" -test.timeout="$TIMEOUT" -test.run=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/sse.go b/ACM-42568_go_migration/sse.go index d80a9326f0..b1cb910cb9 100644 --- a/ACM-42568_go_migration/sse.go +++ b/ACM-42568_go_migration/sse.go @@ -98,6 +98,11 @@ func (cfg Config) CaptureSSE(base string, cs Case, path string, timeout time.Dur 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)