From 369e2ebfd47d2329dd5575e341d3e419a4db6044 Mon Sep 17 00:00:00 2001 From: Rafael Benevides Date: Tue, 8 Sep 2026 17:22:20 -0300 Subject: [PATCH 01/41] HYPERFLEET-1531 - docs: document tenant enforcement across API docs Document the tenant enforcement feature across the operator-facing docs: - config.md: server.tenant.* field reference, env vars, CLI flags, and validation rules - deployment.md: Helm values for tenant enforcement plus schema-enforced invariants and existingConfigMap behavior - authentication.md: trust model (gateway-injected headers, not JWT claims) and request behavior (403/404) - api-operator-guide.md: operator overview, config example, and runtime behavior with troubleshooting entries - api-resources.md: API-visible tenant scoping and the read-only tenancy field - runbook.md: tenant enforcement troubleshooting procedures - database.md: tenancy column, containment-based scoping, fail-closed behavior, and uniqueness vs. visibility --- docs/api-operator-guide.md | 54 +++++++++++++++++++++++++++++++++++++- docs/api-resources.md | 12 +++++++++ docs/authentication.md | 49 ++++++++++++++++++++++++++++++++-- docs/config.md | 45 +++++++++++++++++++++++++++++++ docs/database.md | 35 ++++++++++++++++++++++++ docs/deployment.md | 38 +++++++++++++++++++++++++++ docs/runbook.md | 29 ++++++++++++++++++++ 7 files changed, 259 insertions(+), 3 deletions(-) diff --git a/docs/api-operator-guide.md b/docs/api-operator-guide.md index 6bd84407..ef8bf45e 100644 --- a/docs/api-operator-guide.md +++ b/docs/api-operator-guide.md @@ -26,6 +26,8 @@ A practical guide for deploying, configuring, and operating the HyperFleet API c - [Server Binding](#34-server-binding) - [Logging Configuration](#35-logging-configuration) - [Schema Validation](#36-schema-validation) + - [Tenant Enforcement](#37-tenant-enforcement) + - [Gateway and In-App JWT Modes](#38-gateway-and-in-app-jwt-modes) 4. [Deployment Checklist](#4-deployment-checklist) - [Phase 1: Database Preparation](#phase-1-database-preparation) - [Phase 2: Configuration Planning](#phase-2-configuration-planning) @@ -731,6 +733,55 @@ The API uses a two-step process to validate specs: For details on how schemas are imported for code generation and which schema components map to each resource type, see [openapi/README.md](../openapi/README.md) in this repository. +### 3.7 Tenant Enforcement + +Tenant enforcement scopes each resource read, list, update, and delete to the caller's tenant. It is **disabled by default** and is a separate concern from JWT authentication. + +**Only enable it when the API runs behind the Envoy + Authorino gateway** ([ADR-0020](https://github.com/openshift-hyperfleet/architecture/blob/main/hyperfleet/adrs/0020-envoy-authorino-api-gateway.md)): tenant identity comes from trusted gateway-injected headers, never JWT claims, and the gateway must strip client-supplied tenant headers and block direct routes to the API pod. Without it, clients could forge tenancy. + +Enable it under `server.tenant.*` (`HYPERFLEET_SERVER_TENANT_*` for the scalar fields; dimensions are YAML/Helm-values only): + +```yaml +server: + tenant: + enabled: true + system_header: X-HyperFleet-System # value "true" marks system callers (Sentinel, adapters) + dimensions: + - header: X-HyperFleet-Org # trusted gateway-injected header + key: org # tenancy map key + required: true + - header: X-HyperFleet-Project + key: project + required: false +``` + +At runtime, once enabled: + +- **System callers** (system header value `true`, e.g. Sentinel and adapters) bypass scoping, but may only write `status`/`conditions` (reported through a separate status path). Any other resource mutation — create, update, or delete — from a system identity returns `403 Forbidden`. +- **Tenant-scoped callers** must present the configured dimension headers. A missing required dimension, an invalid value, or zero resolved dimensions is rejected with `403 Forbidden` before any database access. +- **Cross-tenant access** returns `404 Not Found` (not `403`) for reads, updates, and deletes, so a resource's existence is never leaked across tenants. + +For the field reference, environment variables, and validation rules see [Configuration Guide - Tenant Enforcement](config.md#tenant-enforcement); for the full trust model see [Tenant isolation](authentication.md#tenant-isolation). Common tenant errors are in [Appendix B: Troubleshooting](#appendix-b-troubleshooting). + +### 3.8 Gateway and In-App JWT Modes + +The gateway ([ADR-0020](https://github.com/openshift-hyperfleet/architecture/blob/main/hyperfleet/adrs/0020-envoy-authorino-api-gateway.md)) authenticates callers and injects trusted identity/tenant headers; the API's in-app JWT middleware (`server.jwt.enabled`) validates `Bearer` tokens directly. These are independent switches, but not every combination is currently valid. + +Two `Authorization` schemes reach the gateway: human operators use `Bearer `, while machine callers (Sentinel, adapters) use `ServiceAccount `, validated by Kubernetes TokenReview at the gateway. The in-app JWT middleware accepts the `Bearer` scheme **only** — a `ServiceAccount` token presented to the API is rejected with `401 Unauthorized`. This does not block machine callers outright: a Kubernetes service-account JWT presented directly as `Bearer ` (bypassing gateway TokenReview) validates like any other issuer's token — see [Creating a service account token](authentication.md#creating-a-service-account-token). Only the gateway's `ServiceAccount ` scheme itself has no in-app equivalent. + +| `server.jwt.enabled` | `server.tenant.enabled` | Behavior | +|---|---|---| +| `false` | `false` | No auth, no scoping. Local development only (`make run-no-auth`). | +| `true` | `false` | In-app JWT validation, no tenant scoping. `Bearer` callers only — human OIDC or a Kubernetes service-account JWT presented as `Bearer`; the gateway's `ServiceAccount` scheme is not supported. | +| `true` | `true` | Full production posture behind the gateway: JWT validated, requests scoped to gateway-injected tenant headers. `Bearer` callers only; the gateway's `ServiceAccount` scheme is not supported until [HYPERFLEET-1484](https://redhat.atlassian.net/browse/HYPERFLEET-1484). | +| `false` | `true` | Gateway performs all authentication (including machine `ServiceAccount` callers); the API trusts injected headers and scopes on them. | + +A machine caller authenticating with the gateway's `ServiceAccount ` scheme cannot yet traverse the in-app JWT middleware, because it is `Bearer`-only. Until [HYPERFLEET-1484](https://redhat.atlassian.net/browse/HYPERFLEET-1484) adds `ServiceAccount`-scheme support to the in-app middleware, deployments that must authenticate such callers should keep `server.jwt.enabled: false` and let the gateway authenticate every caller (bottom row above). + +#### Mixed dimension cardinality + +Within a single deployment, all callers are expected to resolve the same set of tenant dimensions. Mixing callers that resolve different dimension sets (for example, some presenting only `org` and others presenting `org` + `project`) against the same resources is not yet supported — JSONB containment scoping (`tenancy @> caller`) would let a coarser-scoped caller match finer-scoped resources. Support for heterogeneous dimension cardinality is tracked by [HYPERFLEET-1634](https://redhat.atlassian.net/browse/HYPERFLEET-1634). + --- ## 4. Deployment Checklist @@ -1048,7 +1099,8 @@ This section provides a **quick reference** for common API-specific issues and t | **High API latency, slow responses** | Resource limits, database slow queries, or connection pool exhausted | Check metrics: `curl http://:9090/metrics \| grep hyperfleet_api_request_duration_seconds`. Check resources: `kubectl top pods -n hyperfleet-system`. Check slow queries: `kubectl logs -n hyperfleet-system deployment/hyperfleet-api \| grep "slow query"`. Resolution: Increase resource limits/replicas, add database indexes, or increase `--db-max-open-connections` (default: 50). | | **400 Bad Request** | Resource spec doesn't match OpenAPI schema | Check the loaded schema path: `kubectl logs -n hyperfleet-system deployment/hyperfleet-api \| grep "schema_path"`. Retrieve and inspect the schema: `kubectl exec -n hyperfleet-system deployment/hyperfleet-api -- cat $HYPERFLEET_SERVER_OPENAPI_SCHEMA_PATH`. Validate and fix spec. | | **401 Unauthorized** | Missing or invalid JWT token | Verify authentication is enabled (`server.jwt.enabled=true`). If production, ensure valid JWT token is provided. Reference: [Authentication Guide](authentication.md). | -| **404 Not Found** | Resource doesn't exist | Verify resource ID is correct. Check if resource was deleted: `curl http://:8000/api/hyperfleet/v1/clusters/$CLUSTER_ID`. | +| **403 Forbidden** | Tenant enforcement rejected the caller: missing/empty required dimension header, invalid dimension value, zero resolved dimensions, or a system identity attempting a resource create/update | Confirm the gateway (Envoy + Authorino) is injecting the configured dimension headers and the system header. Check config: `kubectl get configmap -config -o yaml \| grep -A6 tenant`. Verify each `required: true` dimension header is present and its value matches `^[A-Za-z0-9._-]+$` (max 63 chars). A system caller's request must carry the configured system header (`server.tenant.system_header`, e.g. `X-HyperFleet-System`) with the value `true`; system callers may only write status/conditions. See [Tenant isolation](authentication.md#tenant-isolation). | +| **404 Not Found** | Resource doesn't exist, or (with tenant enforcement enabled) the resource belongs to a different tenant | Verify resource ID is correct. Check if resource was deleted: `curl http://:8000/api/hyperfleet/v1/clusters/$CLUSTER_ID`. If tenant enforcement is enabled, confirm the caller's dimension headers scope to the resource's tenancy — cross-tenant resources return 404 by design. | | **409 Conflict** | Concurrent update or generation mismatch | Retry with exponential backoff. Ensure only one controller updates the same resource. | | **500 Internal Server Error** | Database error or unexpected panic | Check API logs: `kubectl logs -n hyperfleet-system -l app=hyperfleet-api --tail=100`. Verify database connectivity with `/readyz` endpoint. | | **503 Service Unavailable** | Readiness probe failing | Check readiness: `curl http://:8080/readyz`. Verify database connectivity and API initialization. Check logs for startup errors. | diff --git a/docs/api-resources.md b/docs/api-resources.md index 9c8ddb82..090b41e0 100644 --- a/docs/api-resources.md +++ b/docs/api-resources.md @@ -10,6 +10,17 @@ Mutating requests (POST, PATCH, PUT, DELETE) additionally require a resolvable c > **Note**: The API does not enforce role-based access control (RBAC). Any authenticated caller can invoke any endpoint, including destructive operations like force-delete. Access control should be enforced at the infrastructure layer (e.g., ingress policies, gateway authorization). +### Tenant scoping + +When tenant enforcement is enabled (`server.tenant.enabled=true`), reads, lists, updates, and deletes are scoped to the caller's tenant, which is resolved from trusted gateway-injected headers (never from JWT claims). This affects API behavior: + +- A resource that belongs to a different tenant returns `404 Not Found`, not `403`, on `GET`, `PATCH`/update, and `DELETE` — cross-tenant existence is never leaked. +- List endpoints return only resources within the caller's tenancy; `total` reflects the scoped result set. +- A non-system caller missing a required tenant dimension header (or presenting an invalid value) is rejected with `403 Forbidden` before the request reaches any resource. +- The `tenancy` field is server-populated on create from the caller's resolved dimensions; it is read-only and any `tenancy` supplied in a create or patch body is ignored. + +System callers (e.g. Sentinel, adapters) bypass scoping but may only write `status`/`conditions` — any other resource mutation (create, update, or delete) from a system identity is rejected with `403 Forbidden`. See [Tenant isolation](authentication.md#tenant-isolation) for details. + ## Cluster Management ### Endpoints @@ -649,6 +660,7 @@ See **[search.md](search.md)** for complete documentation. - `updated_time` - When resource was last updated (API-managed) - `created_by` - User who created the resource (email) - `updated_by` - User who last updated the resource (email) +- `tenancy` - Tenant dimensions the resource belongs to (server-populated, read-only). Present as `{}` when tenant enforcement is disabled or the caller resolved no dimensions. Ignored if supplied in a create/patch body. See [Tenant scoping](#tenant-scoping) ### Status Fields diff --git a/docs/authentication.md b/docs/authentication.md index fd864d79..e176dfa2 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -11,6 +11,8 @@ This document describes authentication mechanisms for the HyperFleet API. - [Issuer configuration reference](#issuer-configuration-reference) - [Creating a service account token](#creating-a-service-account-token) - [Caller identity for audit](#caller-identity-for-audit) +- [Gateway authentication (Envoy and Authorino)](#gateway-authentication-envoy-and-authorino) + - [Trusted header contract](#trusted-header-contract) - [Tenant isolation](#tenant-isolation) - [Configuration](#configuration-1) - [Troubleshooting](#troubleshooting) @@ -282,13 +284,56 @@ server: Identity values from both sources are validated: trimmed of whitespace, limited to 256 characters, and rejected if they contain control characters. +## Gateway authentication (Envoy and Authorino) + +In production, HyperFleet API runs behind the Envoy + Authorino gateway ([ADR-0020](https://github.com/openshift-hyperfleet/architecture/blob/main/hyperfleet/adrs/0020-envoy-authorino-api-gateway.md)), which authenticates every caller and injects trusted identity and tenant headers. Two caller types use different `Authorization` schemes: + +| Caller | Scheme | Validated by | +|--------|--------|--------------| +| Human operators | `Bearer ` | OIDC issuer | +| Sentinel and adapters (machine) | `ServiceAccount ` | Kubernetes TokenReview at the gateway, against a subject allowlist | + +The `ServiceAccount` (TokenReview) validation for machine callers happens at the gateway; the API itself does not perform it. The API's in-app JWT middleware accepts the `Bearer` scheme only and returns `401 Unauthorized` ("authorization header does not use Bearer scheme") for anything else. + +> **Note:** Because the in-app JWT middleware is `Bearer`-only, in-app JWT validation and gateway machine authentication cannot both be enabled for machine callers yet — a `ServiceAccount` token would be rejected by the in-app middleware. Running both is tracked by [HYPERFLEET-1484](https://redhat.atlassian.net/browse/HYPERFLEET-1484). + +### Trusted header contract + +After authenticating a caller, the gateway injects identity and tenant headers derived from validated claims, and the API treats these as authoritative. This is safe only because Envoy strips any client-supplied copy of these headers **before** the authorization filter runs, so a client cannot forge them ([ADR-0020](https://github.com/openshift-hyperfleet/architecture/blob/main/hyperfleet/adrs/0020-envoy-authorino-api-gateway.md)). It applies to: + +- the tenant system header (`server.tenant.system_header`, gateway convention `X-HyperFleet-System`) — value `true` bypasses tenant scoping, and only the gateway may set it +- the caller-identity header (per-issuer `identity_header`, gateway convention `X-HyperFleet-Identity`) — only consumed when `server.jwt.enabled` is `true`; the resolver that reads it is not mounted otherwise, so with JWT disabled the API ignores this header even if tenant scoping is on +- the tenant dimension headers (`server.tenant.dimensions[].header`, gateway convention `X-HyperFleet-Org`, `X-HyperFleet-Project`, …) — resolved independently of JWT, so these still apply when `server.jwt.enabled` is `false` and `server.tenant.enabled` is `true` + +The API reads whatever header names are configured; the `X-HyperFleet-*` names are the gateway's convention, not hardcoded. See [Tenant isolation](#tenant-isolation) for how these drive scoping. + ## Tenant isolation Authentication (JWT validation) and tenant isolation are separate concerns. Tenant identity arrives as trusted headers injected by a gateway (e.g. Envoy + Authorino) — the API does not extract tenant dimensions from JWT claims. This is safe only because the gateway strips client-supplied system/dimension headers, injects validated values, and blocks any direct route to the API pod — see [ADR-0020 — Envoy and Authorino as the API Authentication Gateway](https://github.com/openshift-hyperfleet/architecture/blob/main/hyperfleet/adrs/0020-envoy-authorino-api-gateway.md). -When `server.tenant.enabled` is `true`, a resolver middleware reads a system header (grants an unscoped context, e.g. for internal services like Sentinel and adapters) and configured dimension headers (collected into the caller's tenancy map). A non-system caller missing a required dimension, or resolving zero dimensions, is rejected with `403 Forbidden`. +When `server.tenant.enabled` is `true`, a resolver middleware reads a system header (grants an unscoped context, e.g. for internal services like Sentinel and adapters) and configured dimension headers (collected into the caller's tenancy map). A caller is treated as a system caller when the `system_header` value equals `true` (case-insensitive). A non-system caller missing a required dimension, presenting an invalid dimension value (values must match `^[A-Za-z0-9._-]+$` and be at most 63 characters), or resolving zero dimensions, is rejected with `403 Forbidden` before any database access. + +Once a tenant context is resolved, resource reads, lists, updates, and deletes are scoped to it (see [database.md](database.md#tenant-scoping)). A resource outside the caller's tenancy returns `404 Not Found`, not `403`, on read, update, and delete — this avoids leaking a cross-tenant resource's existence. System callers bypass this scoping entirely, but may only write `status`/`conditions`: any other resource mutation (create, update, or delete) from a system identity is rejected with `403 Forbidden`. + +### Configuration + +The tenant middleware runs after JWT validation and caller-identity resolution when those are mounted (`server.jwt.enabled: true`), and is only mounted itself when `server.tenant.enabled` is `true`. In gateway-only mode (`server.jwt.enabled: false`, `server.tenant.enabled: true`), JWT validation and caller-identity resolution are not mounted at all, and the tenant middleware runs directly off the gateway-injected headers: + +```yaml +server: + tenant: + enabled: true + system_header: X-HyperFleet-System # value "true" marks system callers that bypass scoping + dimensions: + - header: X-HyperFleet-Org # trusted gateway-injected header + key: org # tenancy map key + required: true + - header: X-HyperFleet-Project + key: project + required: false +``` -Once a tenant context is resolved, resource reads, lists, and deletes are scoped to it (see [database.md](database.md#jsonb-fields)). A resource outside the caller's tenancy returns `404 Not Found`, not `403` — this avoids leaking a cross-tenant resource's existence. System callers bypass this scoping entirely. +See [Configuration Guide - Tenant Enforcement](config.md#tenant-enforcement) for the full field reference, environment variables, and validation rules. ## Configuration diff --git a/docs/config.md b/docs/config.md index 7a26292c..5978d59b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -266,6 +266,42 @@ See [Issuer configuration reference](authentication.md#issuer-configuration-refe See [Caller identity for audit](authentication.md#caller-identity-for-audit) for full details on identity resolution, precedence rules, and per-issuer configuration. +### Tenant Enforcement + +Optional per-request tenant scoping. Tenant identity is **not** taken from JWT claims — it arrives as trusted HTTP headers injected by a gateway (Envoy + Authorino). See [Tenant isolation](authentication.md#tenant-isolation) for the conceptual model and trust boundary. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `server.tenant.enabled` | bool | `false` | Enable the tenant enforcement middleware | +| `server.tenant.system_header` | string | `""` | Trusted header marking system callers (e.g. Sentinel, adapters) that bypass scoping. A caller is treated as system when this header's value equals `true` (case-insensitive). Required when `enabled` is `true`. | +| `server.tenant.dimensions` | list | `[]` | YAML only. Tenant dimension mappings. Required (non-empty) when `enabled` is `true`, and at least one entry must have `required: true`. | + +Each entry in `server.tenant.dimensions` has the following fields: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `header` | string | Yes | Trusted gateway-injected HTTP header carrying this dimension's value | +| `key` | string | Yes | Tenancy map key the header value is stored under (drives the `tenancy @> ?` DB match) | +| `required` | bool | No (default `false`) | Whether a non-system caller must present this dimension | + +**Example:** + +```yaml +server: + tenant: + enabled: true + system_header: X-HyperFleet-System + dimensions: + - header: X-HyperFleet-Org + key: org + required: true + - header: X-HyperFleet-Project + key: project + required: false +``` + +Header values for dimensions must be at most 63 characters and match `^[A-Za-z0-9._-]+$`. A non-system caller missing a required dimension, presenting an invalid dimension value, or resolving zero dimensions is rejected with `403 Forbidden` before any database access. +
@@ -412,6 +448,9 @@ Complete table of all configuration properties, their environment variables, and | `server.tls.key_file` | `HYPERFLEET_SERVER_TLS_KEY_FILE` | string | `""` | | `server.jwt.enabled` | `HYPERFLEET_SERVER_JWT_ENABLED` | bool | `true` | | `server.jwt.configs` | (YAML only) | list | `[]` | +| `server.tenant.enabled` | `HYPERFLEET_SERVER_TENANT_ENABLED` | bool | `false` | +| `server.tenant.system_header` | `HYPERFLEET_SERVER_TENANT_SYSTEM_HEADER` | string | `""` | +| `server.tenant.dimensions` | (YAML only) | list | `[]` | | **Database** | | | | | `database.dialect` | `HYPERFLEET_DATABASE_DIALECT` | string | `postgres` | | `database.host` | `HYPERFLEET_DATABASE_HOST` | string | `localhost` | @@ -472,6 +511,8 @@ All CLI flags and their corresponding configuration paths. | `--server-https-cert-file` | `server.tls.cert_file` | string | | `--server-https-key-file` | `server.tls.key_file` | string | | `--server-jwt-enabled` | `server.jwt.enabled` | bool | +| `--server-tenant-enabled` | `server.tenant.enabled` | bool | +| `--server-tenant-system-header` | `server.tenant.system_header` | string | | **Database** | | | | `--db-dialect` | `database.dialect` | string | | `--db-host` | `database.host` | string | @@ -549,6 +590,10 @@ The application performs comprehensive validation at startup. - `server.timeouts.write`: ≥ 1s - `server.jwt.configs`: required non-empty when `server.jwt.enabled=true`; see [Issuer configuration reference](authentication.md#issuer-configuration-reference) for per-field validation rules - `server.jwt.configs[].issuer_url` / `jwk_cert_url`: must use `https` (`http` allowed only for loopback: `localhost`, `127.0.0.1`, `::1`) +- `server.tenant` (validated only when `server.tenant.enabled=true`): + - `system_header`: required; must be a valid HTTP header name and must not be an authentication header (`Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`, `X-Auth-Token`, `X-Forwarded-Authorization`, `Proxy-Authorization`) + - `dimensions`: at least one entry required, and at least one entry must have `required: true` + - `dimensions[].header` / `dimensions[].key`: both required; `header` must be a valid HTTP header name, must not be an authentication header (same denylist as `system_header`), must differ from `system_header`, and must be unique (case-insensitive) across dimensions; `key` must be unique across dimensions **Database**: diff --git a/docs/database.md b/docs/database.md index edae1ad9..dbea6920 100644 --- a/docs/database.md +++ b/docs/database.md @@ -50,6 +50,41 @@ Flexible schema storage for: Adapter statuses do not use soft delete — they are hard-deleted when their parent resource is hard-deleted. +### Tenant Scoping + +When tenant enforcement is enabled, the caller's resolved tenancy is applied as a JSONB containment predicate (`tenancy @> ?`) on reads, lists, updates, and deletes (updates and deletes go through the same scoped `GetForUpdate` lookup, so a cross-tenant target is not found). Matching is by **containment, not equality**: a caller is authorized for a resource when the caller's tenancy map is a subset of the resource's. An org-scoped caller (`{org: acme}`) therefore sees every resource under that org, including those with extra dimensions (`{org: acme, project: platform}`), while a caller scoped to `{org: acme, project: p1}` does not see `{org: acme, project: p2}`. + +#### Where `tenancy` comes from + +On create by a non-system caller, the API fills the resource's `tenancy` column from the caller's resolved dimensions. Clients cannot set it: any `tenancy` sent in the request body is ignored, and the column can never change after creation. So a resource permanently carries the tenancy of whoever created it. + +System identities cannot create resources at all — they may write only `status`/`conditions`, so a create (or any other spec mutation) from a system identity is rejected with `403 Forbidden` before any `tenancy` is assigned. The creator-dimension rule therefore applies only to non-system creates. + +#### Fail-closed on zero dimensions + +A non-system caller that resolves to *no* dimensions is rejected with `403` in the middleware, before any query runs. The DAO has a second safeguard in case such a request ever slips through: instead of running an unscoped query, it applies a `1 = 0` predicate that matches nothing. + +This backstop exists because matching is by containment and the empty map `{}` is a subset of *every* row. Without it, a zero-dimension caller's predicate (`tenancy @> '{}'`) would match every resource — an accidental "see everything." Failing closed turns that into "see nothing." + +#### System callers and pre-existing rows + +System callers (e.g. Sentinel, adapters) skip scoping entirely — no `tenancy` predicate is added, so they see all resources. + +> **Security:** because `System=true` removes the tenancy predicate, the system header is only trustworthy when it originates from the Envoy + Authorino gateway. The gateway must authenticate and allowlist system clients, strip any client-supplied system header, and block direct routes to the API pod (see [ADR-0020](https://github.com/openshift-hyperfleet/architecture/blob/main/hyperfleet/adrs/0020-envoy-authorino-api-gateway.md)). Without that boundary, a spoofed system header — or direct pod access — would expose cross-tenant resources. Only enable tenant enforcement behind such a gateway. + +Rows created *before* enforcement was enabled carry `tenancy = '{}'`. Since a scoped caller is only authorized when its (non-empty) tenancy is a subset of the row's, no scoped caller can be a subset of `{}` — so these legacy rows are visible only to system/unscoped callers. Because `tenancy` is immutable after creation and there is no backfill or re-stamp operation, this is permanent: a legacy `{}` resource can never be brought into a tenant's scope. Enable tenant enforcement before creating tenant-owned resources. + +#### Uniqueness vs. visibility + +These use `tenancy` in two different ways, and it matters: + +- **Uniqueness** — the root-resource index `(kind, name, tenancy)` compares `tenancy` by **exact equality**. +- **Visibility** — access scoping compares `tenancy` by **containment** (subset). + +Because uniqueness is by exact equality, two callers scoped to different tenancy documents can each create a resource with the same `kind` and `name` without colliding — their rows differ in the `tenancy` column. + +See [Tenant isolation](authentication.md#tenant-isolation) for the request-path behavior and trust model. + ### Delete Policies Resources use delete policies to control child behavior when a parent is deleted. Each resource type declares its policy in its entity descriptor: diff --git a/docs/deployment.md b/docs/deployment.md index 012bd354..b7dd3dfc 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -185,6 +185,41 @@ See [Issuer configuration reference](authentication.md#issuer-configuration-refe --- +## Configuring Tenant Enforcement + +Tenant enforcement scopes resource reads, lists, updates, and deletes to the caller's tenant. It is **disabled by default** and is **only safe behind the Envoy + Authorino gateway**, which injects the trusted tenant headers the API relies on. That trust holds only when a NetworkPolicy restricts API pod ingress to the Envoy pod, so no in-cluster workload can reach the API directly and forge tenant headers — see [ADR-0020](https://github.com/openshift-hyperfleet/architecture/blob/main/hyperfleet/adrs/0020-envoy-authorino-api-gateway.md). See [Tenant isolation](authentication.md#tenant-isolation) for the trust model. + +Enable it by setting the `config.server.tenant.*` values: + +```yaml +config: + server: + tenant: + enabled: true + system_header: X-HyperFleet-System # value "true" marks system callers that bypass scoping + dimensions: + - header: X-HyperFleet-Org + key: org + required: true + - header: X-HyperFleet-Project + key: project + required: false +``` + +| Value | Required when tenant enabled | Description | +|-------|------------------------------|-------------| +| `config.server.tenant.enabled` | Yes | Set to `true` | +| `config.server.tenant.system_header` | Yes | Trusted header that marks system callers (Sentinel, adapters). A caller is treated as system when this header's value equals `true`. | +| `config.server.tenant.dimensions` | Yes | List of dimension mappings (`header`, `key`, `required`). At least one entry is required, and at least one must have `required: true`. | + +For tenant values supplied inline through Helm, the chart's `values.schema.json` enforces these invariants at install time, so a misconfigured tenant block (e.g. enabled without `system_header` or without a required dimension) fails `helm install`/`helm upgrade` before reaching the cluster. This check does not cover a tenant block loaded via `config.existingConfigMap` (see the note below) — that ConfigMap is consumed as-is and is not validated against the schema. + +> **Note:** When `config.existingConfigMap` is set, these `config.server.tenant.*` values are ignored — tenant settings must come from the referenced ConfigMap. + +See [Configuration Guide - Tenant Enforcement](config.md#tenant-enforcement) for the full field reference and validation rules. + +--- + ## Configuring Required Adapters Adapters are external components (validation, DNS, pull-secret, HyperShift) that report status back to HyperFleet API. Each entity type declares its required adapters via the `required_adapters` field in the entity descriptor. These define which adapters must report "ready" before a resource is considered **Reconciled**. @@ -359,6 +394,9 @@ helm install hyperfleet-api oci://quay.io/redhat-services-prod/hyperfleet-tenant | `image.tag` | Image tag | `""` (must be set) | | `image.pullPolicy` | Image pull policy | `Always` | | `config.server.jwt.enabled` | Enable JWT authentication | `false` | +| `config.server.tenant.enabled` | Enable tenant enforcement middleware | `false` | +| `config.server.tenant.system_header` | Trusted header marking system callers that bypass tenant scoping | `""` | +| `config.server.tenant.dimensions` | Tenant dimension mappings (`header`, `key`, `required`) | `[]` | | `config.entities` | Entity descriptors (kinds, required adapters, schemas) | (see values.yaml) | | `database.postgresql.enabled` | Enable built-in PostgreSQL | `true` | | `database.external.enabled` | Use external database | `false` | diff --git a/docs/runbook.md b/docs/runbook.md index 6abb3e63..18711c0f 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -296,6 +296,35 @@ kubectl get secret hyperfleet-db -n hyperfleet-system -o go-template='{{range $k 4. Check database is running and accepting connections 5. Verify SSL settings match database requirements +### Tenant Enforcement Issues + +Applies only when tenant enforcement is enabled (`server.tenant.enabled=true`). See [Tenant isolation](authentication.md#tenant-isolation) for the model. + +**Symptoms:** Callers get unexpected `403 Forbidden` on all requests, or `404 Not Found` for resources they expect to see. + +**Diagnosis:** + +```bash +# Inspect the rendered tenant config +kubectl get configmap -config -n hyperfleet-system -o yaml | grep -A8 'tenant:' + +# Look for rejection logs (middleware logs a warning on every rejected request) +kubectl logs deployment/hyperfleet-api -n hyperfleet-system --since=15m | grep -i "Tenant identity rejected" +``` + +**Common causes:** + +- **All requests get 403** — the gateway (Envoy + Authorino) is not injecting the configured dimension headers, or a `required: true` dimension header is missing/empty. Verify the gateway `AuthConfig` injects the headers named in `server.tenant.dimensions[].header`. +- **Invalid dimension value** — dimension header values must match `^[A-Za-z0-9._-]+$` and be ≤ 63 characters; other values are rejected with 403. +- **System caller can't write resources** — system identities (system header value `true`) may only write `status`/`conditions`; any other resource mutation (create, update, or delete) returns 403. This is expected — route resource writes through a tenant-scoped identity. +- **Resources "disappear" (404)** — the resource's tenancy does not contain the caller's resolved tenancy (the caller's dimensions are not a subset of the resource's). Confirm the caller's dimension headers match the tenant the resource was created under. Cross-tenant reads return 404 by design. + +**Resolution:** + +1. Confirm the API sits behind the gateway and direct pod access is blocked (NetworkPolicy) — tenant headers are only trustworthy in that topology. +2. Compare the gateway-injected headers against `server.tenant.system_header` and `server.tenant.dimensions[].header`. +3. If tenant enforcement was enabled after resources already existed, those rows carry `tenancy = {}` and are only visible to unscoped/system callers — see [database.md](database.md#tenant-scoping). + ### Memory Issues **Symptoms:** OOMKilled, high memory usage From b10d9c8510b4abbf2e27b7c461110d60a5f17e1f Mon Sep 17 00:00:00 2001 From: "red-hat-konflux-kflux-prd-rh02[bot]" <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:05:58 +0000 Subject: [PATCH 02/41] Update Konflux references digest Signed-off-by: red-hat-konflux-kflux-prd-rh02 <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> --- .tekton/hyperfleet-api-chart-push.yaml | 12 ++++++------ .tekton/hyperfleet-api-chart-tag.yaml | 12 ++++++------ .tekton/hyperfleet-api-push.yaml | 26 +++++++++++++------------- .tekton/hyperfleet-api-tag.yaml | 26 +++++++++++++------------- 4 files changed, 38 insertions(+), 38 deletions(-) diff --git a/.tekton/hyperfleet-api-chart-push.yaml b/.tekton/hyperfleet-api-chart-push.yaml index 0d906ab3..3b2e2ff2 100644 --- a/.tekton/hyperfleet-api-chart-push.yaml +++ b/.tekton/hyperfleet-api-chart-push.yaml @@ -97,7 +97,7 @@ spec: - name: name value: init - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:4be9343579d91c7b501cafe966cff59a601dfeca903c476e3763dd8d7599b900 + value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:c6c414a3b5dcd827720f3443a128a02d27a0a5071ea3e4dc0063d578f1f73a74 - name: kind value: task resolver: bundles @@ -118,7 +118,7 @@ spec: - name: name value: git-clone-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.2.6@sha256:2e8fe30b6d5c8a8a3e6bbc0ea5a55e05b6170d4a399830f25ea43e17881ce544 + value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.2.6@sha256:1d7ba568ae6e9e26800054f3942779de8abc80ddc48f0e44df6b1c2f098161dc - name: kind value: task resolver: bundles @@ -144,7 +144,7 @@ spec: - name: name value: prefetch-dependencies-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.2@sha256:374f776bcb2048c3adeaf4dbb460c52d001bc6020320df5e878c2d05391302da + value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.3@sha256:9fd7d251f92250d89a5465756e278e053af28c6356e5ccdedc4be87bbb13c190 - name: kind value: task resolver: bundles @@ -170,7 +170,7 @@ spec: - name: name value: build-helm-chart-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.3@sha256:cc59ae16baa756d23f3e5f8328cd9f8ba39a388f8eafbb9db666dea694814d25 + value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.3@sha256:be1234f453a7236fb40c0d8b4d98313f3cfb7e967e1b4816c1881b8fc48ab915 - name: kind value: task resolver: bundles @@ -193,7 +193,7 @@ spec: - name: name value: sast-shell-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:afa8ba8859739e48b672f66fa2af357d27f4d96846a7c0ad84e38f21b043f695 + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:d9b01530ce3c20287714e64980f154e28c0e94d17e2dbfbaf3c8bf77b1844b9e - name: kind value: task resolver: bundles @@ -221,7 +221,7 @@ spec: - name: name value: sast-unicode-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:69d5fca2fb94dcc7df32e36e4828e6fb24b9ba55b1837c331a83e20d8dfd479e + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:381750451fbcc86d90fa83579a48e0e6555d7cf374fe2c4af3f9262a17fc3c89 - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-chart-tag.yaml b/.tekton/hyperfleet-api-chart-tag.yaml index 66321601..bf5fc757 100644 --- a/.tekton/hyperfleet-api-chart-tag.yaml +++ b/.tekton/hyperfleet-api-chart-tag.yaml @@ -103,7 +103,7 @@ spec: - name: name value: init - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:4be9343579d91c7b501cafe966cff59a601dfeca903c476e3763dd8d7599b900 + value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:c6c414a3b5dcd827720f3443a128a02d27a0a5071ea3e4dc0063d578f1f73a74 - name: kind value: task resolver: bundles @@ -147,7 +147,7 @@ spec: - name: name value: git-clone-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.2.6@sha256:2e8fe30b6d5c8a8a3e6bbc0ea5a55e05b6170d4a399830f25ea43e17881ce544 + value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.2.6@sha256:1d7ba568ae6e9e26800054f3942779de8abc80ddc48f0e44df6b1c2f098161dc - name: kind value: task resolver: bundles @@ -173,7 +173,7 @@ spec: - name: name value: prefetch-dependencies-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.2@sha256:374f776bcb2048c3adeaf4dbb460c52d001bc6020320df5e878c2d05391302da + value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.3@sha256:9fd7d251f92250d89a5465756e278e053af28c6356e5ccdedc4be87bbb13c190 - name: kind value: task resolver: bundles @@ -203,7 +203,7 @@ spec: - name: name value: build-helm-chart-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.3@sha256:cc59ae16baa756d23f3e5f8328cd9f8ba39a388f8eafbb9db666dea694814d25 + value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.3@sha256:be1234f453a7236fb40c0d8b4d98313f3cfb7e967e1b4816c1881b8fc48ab915 - name: kind value: task resolver: bundles @@ -226,7 +226,7 @@ spec: - name: name value: sast-shell-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:afa8ba8859739e48b672f66fa2af357d27f4d96846a7c0ad84e38f21b043f695 + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:d9b01530ce3c20287714e64980f154e28c0e94d17e2dbfbaf3c8bf77b1844b9e - name: kind value: task resolver: bundles @@ -254,7 +254,7 @@ spec: - name: name value: sast-unicode-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:69d5fca2fb94dcc7df32e36e4828e6fb24b9ba55b1837c331a83e20d8dfd479e + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:381750451fbcc86d90fa83579a48e0e6555d7cf374fe2c4af3f9262a17fc3c89 - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-push.yaml b/.tekton/hyperfleet-api-push.yaml index 9a628073..386098b5 100644 --- a/.tekton/hyperfleet-api-push.yaml +++ b/.tekton/hyperfleet-api-push.yaml @@ -146,7 +146,7 @@ spec: - name: name value: init - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:4be9343579d91c7b501cafe966cff59a601dfeca903c476e3763dd8d7599b900 + value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:c6c414a3b5dcd827720f3443a128a02d27a0a5071ea3e4dc0063d578f1f73a74 - name: kind value: task resolver: bundles @@ -167,7 +167,7 @@ spec: - name: name value: git-clone-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.2.6@sha256:2e8fe30b6d5c8a8a3e6bbc0ea5a55e05b6170d4a399830f25ea43e17881ce544 + value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.2.6@sha256:1d7ba568ae6e9e26800054f3942779de8abc80ddc48f0e44df6b1c2f098161dc - name: kind value: task resolver: bundles @@ -193,7 +193,7 @@ spec: - name: name value: prefetch-dependencies-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.2@sha256:374f776bcb2048c3adeaf4dbb460c52d001bc6020320df5e878c2d05391302da + value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.3@sha256:9fd7d251f92250d89a5465756e278e053af28c6356e5ccdedc4be87bbb13c190 - name: kind value: task resolver: bundles @@ -251,7 +251,7 @@ spec: - name: name value: buildah-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.12.1@sha256:d44fb2d0e1bb5eb916777bf129b9d6b5e8c08a14f6c4505dc67bce22660b3d9f + value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.12.1@sha256:dd0c817c0e3ded8ff52b6c227681ea542e17211bf85de38101b9b11029d16bac - name: kind value: task resolver: bundles @@ -273,7 +273,7 @@ spec: - name: name value: build-image-index - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.3.1@sha256:290c9ec319423ff9ae7b2cb78fa859e1d333abcdd2ef6c001533377812020071 + value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.3.1@sha256:6ead03ca82a1b970dc60eaadae86adf374e8e70f8d951902e1fac7411dbe0550 - name: kind value: task resolver: bundles @@ -294,7 +294,7 @@ spec: - name: name value: source-build-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.3.1@sha256:1808485d95cf77fb7912f6fe69191bead05fc0f2f71e00031941a7ea38a5f665 + value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.3.1@sha256:bbad5bfd67d1263320a0c4d3f062a18ae611e081b009fd96117c2a00a6ce8dd9 - name: kind value: task resolver: bundles @@ -358,7 +358,7 @@ spec: - name: name value: ecosystem-cert-preflight-checks - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:c07d2befa8abf48d4a223a5bdf5ea2335e756d4d0bbc422761087c263060aa94 + value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:fa53ef450ba538addafee1211ff6ac15b55865ce652fec15ac99b554680cb642 - name: kind value: task resolver: bundles @@ -386,7 +386,7 @@ spec: - name: name value: sast-snyk-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.5@sha256:67a409de3c99aeaee4596da3081f26955ca6201a7f12cb6a9912659bdbcc4d01 + value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.5@sha256:a973b32f0e11958aba08a635981e4d4e7eb200c417492371a1e692ff589b4e8f - name: kind value: task resolver: bundles @@ -436,7 +436,7 @@ spec: - name: name value: sast-shell-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:afa8ba8859739e48b672f66fa2af357d27f4d96846a7c0ad84e38f21b043f695 + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:d9b01530ce3c20287714e64980f154e28c0e94d17e2dbfbaf3c8bf77b1844b9e - name: kind value: task resolver: bundles @@ -464,7 +464,7 @@ spec: - name: name value: sast-unicode-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:69d5fca2fb94dcc7df32e36e4828e6fb24b9ba55b1837c331a83e20d8dfd479e + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:381750451fbcc86d90fa83579a48e0e6555d7cf374fe2c4af3f9262a17fc3c89 - name: kind value: task resolver: bundles @@ -486,7 +486,7 @@ spec: - name: name value: apply-tags - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.3.1@sha256:ccd3665345d86c6799bc7e2e6ad86b277d9f3a5c40b513e6f2a0af8ad92e7dba + value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.3.1@sha256:b1c71d83be8e6d0e44f9b6e58e1ad491396d4c287be9291802c9be69d9441f2f - name: kind value: task resolver: bundles @@ -509,7 +509,7 @@ spec: - name: name value: push-dockerfile-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile-oci-ta:0.3.1@sha256:ef00a86cb22259fcfdefa15a5116b63d0f24ee35c95d05ff9815ee8f84beb548 + value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile-oci-ta:0.3.1@sha256:3e59d6303ca031c27fab079f7c743fbdecc53d007b8d86e32227de5164f06e31 - name: kind value: task resolver: bundles @@ -526,7 +526,7 @@ spec: - name: name value: rpms-signature-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:9ef4dabd53e823e3139b99c8de708be4ee759d63b32982847929df19ab75b2f8 + value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:64bb1cf7d875157e68043a411f00e7852afffc68f50881fb041ba994109f4763 - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-tag.yaml b/.tekton/hyperfleet-api-tag.yaml index 936e5984..3af2cf1d 100644 --- a/.tekton/hyperfleet-api-tag.yaml +++ b/.tekton/hyperfleet-api-tag.yaml @@ -153,7 +153,7 @@ spec: - name: name value: init - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:4be9343579d91c7b501cafe966cff59a601dfeca903c476e3763dd8d7599b900 + value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:c6c414a3b5dcd827720f3443a128a02d27a0a5071ea3e4dc0063d578f1f73a74 - name: kind value: task resolver: bundles @@ -197,7 +197,7 @@ spec: - name: name value: git-clone-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.2.6@sha256:2e8fe30b6d5c8a8a3e6bbc0ea5a55e05b6170d4a399830f25ea43e17881ce544 + value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.2.6@sha256:1d7ba568ae6e9e26800054f3942779de8abc80ddc48f0e44df6b1c2f098161dc - name: kind value: task resolver: bundles @@ -223,7 +223,7 @@ spec: - name: name value: prefetch-dependencies-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.2@sha256:374f776bcb2048c3adeaf4dbb460c52d001bc6020320df5e878c2d05391302da + value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.3@sha256:9fd7d251f92250d89a5465756e278e053af28c6356e5ccdedc4be87bbb13c190 - name: kind value: task resolver: bundles @@ -281,7 +281,7 @@ spec: - name: name value: buildah-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.12.1@sha256:d44fb2d0e1bb5eb916777bf129b9d6b5e8c08a14f6c4505dc67bce22660b3d9f + value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.12.1@sha256:dd0c817c0e3ded8ff52b6c227681ea542e17211bf85de38101b9b11029d16bac - name: kind value: task resolver: bundles @@ -303,7 +303,7 @@ spec: - name: name value: build-image-index - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.3.1@sha256:290c9ec319423ff9ae7b2cb78fa859e1d333abcdd2ef6c001533377812020071 + value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.3.1@sha256:6ead03ca82a1b970dc60eaadae86adf374e8e70f8d951902e1fac7411dbe0550 - name: kind value: task resolver: bundles @@ -324,7 +324,7 @@ spec: - name: name value: source-build-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.3.1@sha256:1808485d95cf77fb7912f6fe69191bead05fc0f2f71e00031941a7ea38a5f665 + value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.3.1@sha256:bbad5bfd67d1263320a0c4d3f062a18ae611e081b009fd96117c2a00a6ce8dd9 - name: kind value: task resolver: bundles @@ -388,7 +388,7 @@ spec: - name: name value: ecosystem-cert-preflight-checks - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:c07d2befa8abf48d4a223a5bdf5ea2335e756d4d0bbc422761087c263060aa94 + value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:fa53ef450ba538addafee1211ff6ac15b55865ce652fec15ac99b554680cb642 - name: kind value: task resolver: bundles @@ -416,7 +416,7 @@ spec: - name: name value: sast-snyk-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.5@sha256:67a409de3c99aeaee4596da3081f26955ca6201a7f12cb6a9912659bdbcc4d01 + value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.5@sha256:a973b32f0e11958aba08a635981e4d4e7eb200c417492371a1e692ff589b4e8f - name: kind value: task resolver: bundles @@ -466,7 +466,7 @@ spec: - name: name value: sast-shell-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:afa8ba8859739e48b672f66fa2af357d27f4d96846a7c0ad84e38f21b043f695 + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:d9b01530ce3c20287714e64980f154e28c0e94d17e2dbfbaf3c8bf77b1844b9e - name: kind value: task resolver: bundles @@ -494,7 +494,7 @@ spec: - name: name value: sast-unicode-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:69d5fca2fb94dcc7df32e36e4828e6fb24b9ba55b1837c331a83e20d8dfd479e + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:381750451fbcc86d90fa83579a48e0e6555d7cf374fe2c4af3f9262a17fc3c89 - name: kind value: task resolver: bundles @@ -516,7 +516,7 @@ spec: - name: name value: apply-tags - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.3.1@sha256:ccd3665345d86c6799bc7e2e6ad86b277d9f3a5c40b513e6f2a0af8ad92e7dba + value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.3.1@sha256:b1c71d83be8e6d0e44f9b6e58e1ad491396d4c287be9291802c9be69d9441f2f - name: kind value: task resolver: bundles @@ -539,7 +539,7 @@ spec: - name: name value: push-dockerfile-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile-oci-ta:0.3.1@sha256:ef00a86cb22259fcfdefa15a5116b63d0f24ee35c95d05ff9815ee8f84beb548 + value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile-oci-ta:0.3.1@sha256:3e59d6303ca031c27fab079f7c743fbdecc53d007b8d86e32227de5164f06e31 - name: kind value: task resolver: bundles @@ -556,7 +556,7 @@ spec: - name: name value: rpms-signature-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:9ef4dabd53e823e3139b99c8de708be4ee759d63b32982847929df19ab75b2f8 + value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:64bb1cf7d875157e68043a411f00e7852afffc68f50881fb041ba994109f4763 - name: kind value: task resolver: bundles From 681845a74b95fdf9037c33f650f2c0cf2e4e40e9 Mon Sep 17 00:00:00 2001 From: "red-hat-konflux-kflux-prd-rh02[bot]" <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:03:56 +0000 Subject: [PATCH 03/41] Update registry.access.redhat.com/ubi9/go-toolset Docker tag to v9.8-1789040808 Signed-off-by: red-hat-konflux-kflux-prd-rh02 <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7a90e31f..d3ee18f0 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ ARG BASE_IMAGE=registry.access.redhat.com/ubi9-micro:latest -FROM registry.access.redhat.com/ubi9/go-toolset:9.8-1788409979 AS builder +FROM registry.access.redhat.com/ubi9/go-toolset:9.8-1789040808 AS builder ARG GIT_SHA=unknown ARG GIT_DIRTY="" From 082eca10c5c11c0428403cee5abf68991b6bd1f1 Mon Sep 17 00:00:00 2001 From: Ciaran Roche Date: Tue, 15 Sep 2026 11:56:30 +0100 Subject: [PATCH 04/41] HYPERFLEET-1651 - fix: pin Helm chart task to working digest --- .tekton/hyperfleet-api-chart-push.yaml | 2 +- .tekton/hyperfleet-api-chart-tag.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.tekton/hyperfleet-api-chart-push.yaml b/.tekton/hyperfleet-api-chart-push.yaml index 3b2e2ff2..cc5daa25 100644 --- a/.tekton/hyperfleet-api-chart-push.yaml +++ b/.tekton/hyperfleet-api-chart-push.yaml @@ -170,7 +170,7 @@ spec: - name: name value: build-helm-chart-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.3@sha256:be1234f453a7236fb40c0d8b4d98313f3cfb7e967e1b4816c1881b8fc48ab915 + value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.3@sha256:cc59ae16baa756d23f3e5f8328cd9f8ba39a388f8eafbb9db666dea694814d25 - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-chart-tag.yaml b/.tekton/hyperfleet-api-chart-tag.yaml index bf5fc757..de62fb50 100644 --- a/.tekton/hyperfleet-api-chart-tag.yaml +++ b/.tekton/hyperfleet-api-chart-tag.yaml @@ -203,7 +203,7 @@ spec: - name: name value: build-helm-chart-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.3@sha256:be1234f453a7236fb40c0d8b4d98313f3cfb7e967e1b4816c1881b8fc48ab915 + value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.3@sha256:cc59ae16baa756d23f3e5f8328cd9f8ba39a388f8eafbb9db666dea694814d25 - name: kind value: task resolver: bundles From 8ef86e7d13ec6613e14f5db10e263e1851fea60d Mon Sep 17 00:00:00 2001 From: Ciaran Roche Date: Tue, 15 Sep 2026 12:32:32 +0100 Subject: [PATCH 05/41] HYPERFLEET-1651 - ci: prefetch tools module for Hermeto The push and tag pipelines prefetch only the root Go module, but make build runs the code generator through go tool -modfile=tools/go.mod. Hermeto points GOPROXY at its prefetch output, so oapi-codegen is missing and every on-push build has failed since the prefetch was added. Prefetch the tools module as a second gomod path so the generator resolves and its packages carry Hermeto attribution. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018EkLeTwdmrofNMwKRpDCWG --- .tekton/hyperfleet-api-push.yaml | 2 +- .tekton/hyperfleet-api-tag.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.tekton/hyperfleet-api-push.yaml b/.tekton/hyperfleet-api-push.yaml index 386098b5..da32edd9 100644 --- a/.tekton/hyperfleet-api-push.yaml +++ b/.tekton/hyperfleet-api-push.yaml @@ -29,7 +29,7 @@ spec: - name: path-context value: . - name: prefetch-input - value: '{"type": "gomod"}' + value: '[{"type": "gomod", "path": "."}, {"type": "gomod", "path": "tools"}]' pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while maintaining trust after pipeline customization. diff --git a/.tekton/hyperfleet-api-tag.yaml b/.tekton/hyperfleet-api-tag.yaml index 3af2cf1d..77d6d9b1 100644 --- a/.tekton/hyperfleet-api-tag.yaml +++ b/.tekton/hyperfleet-api-tag.yaml @@ -32,7 +32,7 @@ spec: - name: path-context value: . - name: prefetch-input - value: '{"type": "gomod"}' + value: '[{"type": "gomod", "path": "."}, {"type": "gomod", "path": "tools"}]' pipelineSpec: description: | This pipeline is ideal for building container images from a Containerfile while maintaining trust after pipeline customization. From f7a763524c0eaa97d5ce8f69ebd58c2c0cdbcfa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michaela=20Pa=C5=99ilov=C3=A1?= Date: Mon, 14 Sep 2026 18:08:56 +0200 Subject: [PATCH 06/41] HYPERFLEET-1298 - fix: reject literal-only tautology comparisons --- pkg/db/sql_tsl.go | 64 ++++++++++++++++ pkg/db/sql_tsl_test.go | 75 +++++++++++++++++++ test/integration/search_field_mapping_test.go | 23 ++++++ 3 files changed, 162 insertions(+) diff --git a/pkg/db/sql_tsl.go b/pkg/db/sql_tsl.go index fb8b0c44..ccb07842 100644 --- a/pkg/db/sql_tsl.go +++ b/pkg/db/sql_tsl.go @@ -110,10 +110,25 @@ func isConditionNode(n *tsl.TSLNode) bool { // subqueries; JSONB mapping, CAST wrapping, and table-name prefixing all // happen during emission. func TSLToSQL(node *tsl.TSLNode, cfg WalkConfig) (string, []any, *errors.ServiceError) { + if svcErr := requirePredicate(node); svcErr != nil { + return "", nil, svcErr + } ctx := &walkContext{cfg: cfg} return walkNode(node, ctx) } +// requirePredicate rejects a bare literal or field used as a full predicate (e.g. "OR true") +func requirePredicate(n *tsl.TSLNode) *errors.ServiceError { + if n == nil { + return nil + } + if n.Type() != tsl.KindBinaryExpr && n.Type() != tsl.KindUnaryExpr { + return errors.BadRequest( + "search comparisons must reference a field; literal-only comparisons are not allowed") + } + return nil +} + func walkNode(n *tsl.TSLNode, ctx *walkContext) (string, []any, *errors.ServiceError) { if n == nil { return "", nil, nil @@ -294,6 +309,9 @@ func walkNaryExpr(n *tsl.TSLNode, ctx *walkContext) (string, []any, *errors.Serv case tsl.OpLike, tsl.OpILike: return walkStringMatch(op, ctx) case tsl.OpNot: + if svcErr := requirePredicate(op.Right); svcErr != nil { + return "", nil, svcErr + } childCtx := &walkContext{cfg: ctx.cfg, inNot: true} childSQL, childArgs, err := walkNode(op.Right, childCtx) if err != nil { @@ -313,6 +331,12 @@ func walkNaryExpr(n *tsl.TSLNode, ctx *walkContext) (string, []any, *errors.Serv } func walkLogical(op tsl.TSLExpressionOp, ctx *walkContext) (string, []any, *errors.ServiceError) { + if svcErr := requirePredicate(op.Left); svcErr != nil { + return "", nil, svcErr + } + if svcErr := requirePredicate(op.Right); svcErr != nil { + return "", nil, svcErr + } leftSQL, leftArgs, err := walkNode(op.Left, ctx) if err != nil { return "", nil, err @@ -337,6 +361,10 @@ func walkLogical(op tsl.TSLExpressionOp, ctx *walkContext) (string, []any, *erro } func walkComparison(op tsl.TSLExpressionOp, ctx *walkContext) (string, []any, *errors.ServiceError) { //nolint:cyclop + if isConstantExpr(op.Left) && isConstantExpr(op.Right) { + return "", nil, errors.BadRequest( + "search comparisons must reference a field; literal-only comparisons are not allowed") + } if svcErr := validateTypedSide(op.Left, op.Right); svcErr != nil { return "", nil, svcErr } @@ -389,6 +417,10 @@ func walkIn(op tsl.TSLExpressionOp, ctx *walkContext) (string, []any, *errors.Se return "", nil, errors.BadRequest( "IN is not supported for condition queries; use comparison operators (=, !=, <, <=, >, >=)") } + if isConstantExpr(op.Left) { + return "", nil, errors.BadRequest( + "search comparisons must reference a field; literal-only comparisons are not allowed") + } if svcErr := validateTypedArray(op.Left, op.Right); svcErr != nil { return "", nil, svcErr } @@ -436,6 +468,10 @@ func walkBetween(op tsl.TSLExpressionOp, ctx *walkContext) (string, []any, *erro return "", nil, errors.BadRequest( "BETWEEN is not supported for condition queries; use comparison operators (=, !=, <, <=, >, >=)") } + if isConstantExpr(op.Left) { + return "", nil, errors.BadRequest( + "search comparisons must reference a field; literal-only comparisons are not allowed") + } leftSQL, leftArgs, err := walkNode(op.Left, ctx) if err != nil { return "", nil, err @@ -471,6 +507,10 @@ func walkBetween(op tsl.TSLExpressionOp, ctx *walkContext) (string, []any, *erro } func walkIsNull(op tsl.TSLExpressionOp, ctx *walkContext) (string, []any, *errors.ServiceError) { + if isConstantExpr(op.Left) { + return "", nil, errors.BadRequest( + "search comparisons must reference a field; literal-only comparisons are not allowed") + } leftSQL, leftArgs, err := walkNode(op.Left, ctx) if err != nil { return "", nil, err @@ -484,6 +524,10 @@ func walkStringMatch(op tsl.TSLExpressionOp, ctx *walkContext) (string, []any, * return "", nil, errors.BadRequest( "LIKE/ILIKE is not supported for condition queries; use comparison operators (=, !=, <, <=, >, >=)") } + if isConstantExpr(op.Left) && isConstantExpr(op.Right) { + return "", nil, errors.BadRequest( + "search comparisons must reference a field; literal-only comparisons are not allowed") + } leftSQL, leftArgs, err := walkNode(op.Left, ctx) if err != nil { return "", nil, err @@ -522,6 +566,26 @@ func walkArrayLiteral(n *tsl.TSLNode, ctx *walkContext) (string, []any, *errors. return fmt.Sprintf("(%s)", strings.Join(placeholders, ", ")), args, nil } +// isConstantExpr reports whether n has no field reference (a literal, or +// arithmetic over literals like (1+1)). +func isConstantExpr(n *tsl.TSLNode) bool { + if n == nil { + return true + } + switch n.Type() { + case tsl.KindIdentifier: + return false + case tsl.KindBinaryExpr, tsl.KindUnaryExpr: + op, ok := n.AsExprOp() + if !ok { + return false + } + return isConstantExpr(op.Left) && isConstantExpr(op.Right) + default: + return true + } +} + // validateTypedSide checks that if ident is a typed field (generation, timestamps), // the value node has the correct TSL literal kind. func validateTypedSide(ident, value *tsl.TSLNode) *errors.ServiceError { diff --git a/pkg/db/sql_tsl_test.go b/pkg/db/sql_tsl_test.go index 1febff13..047358ec 100644 --- a/pkg/db/sql_tsl_test.go +++ b/pkg/db/sql_tsl_test.go @@ -714,6 +714,81 @@ func TestTSLToSQL_LogicalOperators(t *testing.T) { }) } +func TestTSLToSQL_TautologyRejection(t *testing.T) { + const tautologyErr = "literal-only comparisons are not allowed" + + tests := []struct { + expectValue any + name string + query string + wantErrContains string + expectSQL string + }{ + {name: "numeric literal tautology", query: "1 = 1", wantErrContains: tautologyErr}, + {name: "string literal tautology", query: "'a' = 'a'", wantErrContains: tautologyErr}, + {name: "boolean literal tautology", query: "true = true", wantErrContains: tautologyErr}, + {name: "literal-vs-literal with unequal values is also rejected", query: "1 = 2", wantErrContains: tautologyErr}, + { + name: "literal tautology bypass via OR is rejected", query: "name = 'test' OR 1 = 1", + wantErrContains: tautologyErr, + }, + {name: "arithmetic-only comparison is rejected", query: "(1 + 1) = 2", wantErrContains: tautologyErr}, + { + name: "field vs literal is allowed", query: "name = 'test'", + expectSQL: "resources.name = ?", expectValue: "test", + }, + { + name: "literal vs field is allowed", query: "'test' = name", + expectSQL: "? = resources.name", expectValue: "test", + }, + {name: "field vs field is allowed", query: "name = kind", expectSQL: "resources.name = resources.kind"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.wantErrContains != "" { + svcErr := walkExpectError(t, tt.query) + Expect(svcErr.Error()).To(ContainSubstring(tt.wantErrContains)) + return + } + sql, values := walkHelper(t, tt.query) + Expect(sql).To(Equal(tt.expectSQL)) + if tt.expectValue != nil { + Expect(values).To(ConsistOf(tt.expectValue)) + } + }) + } +} + +func TestTSLToSQL_PredicateTautologyRejection(t *testing.T) { + const tautologyErr = "literal-only comparisons are not allowed" + + tests := []struct { + name string + query string + }{ + {name: "constant IN", query: "1 IN [1, 2]"}, + {name: "constant IN bypass via OR", query: "name = 'test' OR 1 IN [1]"}, + {name: "constant BETWEEN", query: "1 BETWEEN 0 AND 10"}, + {name: "constant BETWEEN bypass via OR", query: "name = 'test' OR 1 BETWEEN 0 AND 10"}, + {name: "constant IS NULL", query: "1 IS NULL"}, + {name: "constant IS NULL bypass via OR", query: "name = 'test' OR 1 IS NULL"}, + {name: "constant LIKE", query: "'a' LIKE 'a'"}, + {name: "constant LIKE bypass via OR", query: "name = 'test' OR 'a' LIKE 'a'"}, + {name: "bare boolean literal as whole query", query: "true"}, + {name: "bare boolean literal under NOT", query: "NOT false"}, + {name: "bare boolean literal bypass via OR", query: "name = 'test' OR true"}, + {name: "bare boolean literal under NOT bypass via OR", query: "name = 'test' OR NOT false"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svcErr := walkExpectError(t, tt.query) + Expect(svcErr.Error()).To(ContainSubstring(tautologyErr)) + }) + } +} + func TestTSLToSQL_StringMatch(t *testing.T) { t.Run("LIKE", func(t *testing.T) { RegisterTestingT(t) diff --git a/test/integration/search_field_mapping_test.go b/test/integration/search_field_mapping_test.go index bfd7febf..0afb9c48 100644 --- a/test/integration/search_field_mapping_test.go +++ b/test/integration/search_field_mapping_test.go @@ -1127,3 +1127,26 @@ func TestSearchNonexistentField(t *testing.T) { Expect(body).NotTo(ContainSubstring("SQLSTATE")) Expect(body).To(ContainSubstring("HYPERFLEET-VAL-005")) } + +// TestSearchTautologyRejected verifies that a literal-vs-literal tautology +// bypass is rejected with 400 +func TestSearchTautologyRejected(t *testing.T) { + RegisterTestingT(t) + h, client := test.RegisterIntegration(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + search := openapi.SearchParams("name = 'test' OR 1 = 1") + params := &openapi.GetClustersParams{ + Search: &search, + } + resp, err := client.GetClustersWithResponse(ctx, params, test.WithAuthToken(ctx)) + + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusBadRequest)) + body := string(resp.Body) + Expect(body).NotTo(ContainSubstring("pq:")) + Expect(body).NotTo(ContainSubstring("SQLSTATE")) + Expect(body).To(ContainSubstring("HYPERFLEET-VAL-005")) +} From bb1cfbf81396f1be012d5d76735da9b187417560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michaela=20Pa=C5=99ilov=C3=A1?= Date: Tue, 15 Sep 2026 12:06:03 +0200 Subject: [PATCH 07/41] HYPERFLEET-1298 - fix: remove unvalidated table.column search fallback --- pkg/dao/generic.go | 58 ------------------- pkg/dao/mocks/generic.go | 5 -- pkg/db/sql_tsl.go | 21 +++---- pkg/db/sql_tsl_test.go | 57 +++++------------- pkg/services/generic.go | 56 ------------------ test/integration/search_field_mapping_test.go | 26 ++++++++- 6 files changed, 45 insertions(+), 178 deletions(-) diff --git a/pkg/dao/generic.go b/pkg/dao/generic.go index 7530b0ad..0d2d1e1c 100755 --- a/pkg/dao/generic.go +++ b/pkg/dao/generic.go @@ -2,9 +2,7 @@ package dao import ( "context" - "strings" - "github.com/jinzhu/inflection" "gorm.io/gorm" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" @@ -28,14 +26,11 @@ type GenericDao interface { GetInstanceDao(ctx context.Context, model interface{}) GenericDao Preload(preload string) OrderBy(orderBy string) - Joins(sql string) - Group(sql string) Where(where Where) Count(model interface{}, total *int64) error Validate(resourceList interface{}) error GetTableName() string - GetTableRelation(fieldName string) (TableRelation, bool) } var _ GenericDao = &sqlGenericDao{} @@ -45,15 +40,6 @@ type sqlGenericDao struct { g2 *gorm.DB } -// TableRelation represents a relationship between two tables. They can be joined, -// ON TableName.ColumnName = ForeignTableName.ForeignColumnName -type TableRelation struct { - TableName string - ColumnName string - ForeignTableName string - ForeignColumnName string -} - func NewGenericDao(sessionFactory db.SessionFactory) GenericDao { return &sqlGenericDao{sessionFactory: sessionFactory} } @@ -77,14 +63,6 @@ func (d *sqlGenericDao) OrderBy(orderBy string) { d.g2 = d.g2.Order(orderBy) } -func (d *sqlGenericDao) Joins(sql string) { - d.g2 = d.g2.Joins(sql) -} - -func (d *sqlGenericDao) Group(sql string) { - d.g2 = d.g2.Group(sql) -} - func (d *sqlGenericDao) Where(where Where) { d.g2 = d.g2.Where(where.sql, where.values...) } @@ -114,39 +92,3 @@ func (d *sqlGenericDao) Validate(resourceList interface{}) error { func (d *sqlGenericDao) GetTableName() string { return db.GetTableName(d.g2) } - -// extract the relation from the api model -func (d *sqlGenericDao) GetTableRelation(fieldName string) (TableRelation, bool) { - // try singular - fieldName = strings.ToUpper(fieldName[:1]) + fieldName[1:] - table := inflection.Singular(fieldName) - association := d.g2.Association(table) - // the relation must exist in the model - if association.Relationship == nil { - // try plural - table = inflection.Plural(fieldName) - association = d.g2.Association(table) - if association.Relationship == nil { - return TableRelation{}, false - } - } - - if association.Relationship.Type != "belongs_to" && association.Relationship.Type != "has_many" { - // we don't use has_one or many_to_many relations - return TableRelation{}, false - } - - columnName := association.Relationship.References[0].ForeignKey.DBName - foreignColumnName := association.Relationship.References[0].PrimaryKey.DBName - if association.Relationship.Type == "has_many" { - columnName = association.Relationship.References[0].PrimaryKey.DBName - foreignColumnName = association.Relationship.References[0].ForeignKey.DBName - } - - return TableRelation{ - TableName: association.Relationship.Field.Schema.Table, - ForeignTableName: association.Relationship.FieldSchema.Table, - ForeignColumnName: foreignColumnName, - ColumnName: columnName, - }, true -} diff --git a/pkg/dao/mocks/generic.go b/pkg/dao/mocks/generic.go index 250aa96c..e48002b2 100755 --- a/pkg/dao/mocks/generic.go +++ b/pkg/dao/mocks/generic.go @@ -64,8 +64,3 @@ func (g *genericDaoMock) GetTableName() string { // Mock implementation - returns empty string return "" } - -func (g *genericDaoMock) GetTableRelation(fieldName string) (dao.TableRelation, bool) { - // Mock implementation - returns empty relation and false - return dao.TableRelation{}, false -} diff --git a/pkg/db/sql_tsl.go b/pkg/db/sql_tsl.go index ccb07842..4e3e80b2 100644 --- a/pkg/db/sql_tsl.go +++ b/pkg/db/sql_tsl.go @@ -85,10 +85,9 @@ var typedKindHints = map[tsl.Kind]string{ tsl.KindTimestampLiteral: "an RFC3339 timestamp (e.g. 2026-01-01T00:00:00Z)", } -// WalkConfig provides table context and a hook for related-table resolution. +// WalkConfig provides table context for the walk type WalkConfig struct { - ResolveRelated func(name string) (string, error) - TableName string + TableName string } type walkContext struct { @@ -279,19 +278,13 @@ func resolveField(name string, ctx *walkContext) (string, []any, *errors.Service } } - if len(fieldParts) == 1 { - return fmt.Sprintf("%s.%s", ctx.cfg.TableName, trimmedName), nil, nil + // dotted fields are only supported via the labels./spec./status.conditions. + // prefixes handled above in resolveColumn + if len(fieldParts) != 1 { + return "", nil, errors.BadRequest("%s is not a valid field name", name) } - if ctx.cfg.ResolveRelated != nil { - resolved, relErr := ctx.cfg.ResolveRelated(name) - if relErr != nil { - return "", nil, errors.BadRequest("%s", relErr.Error()) - } - return resolved, nil, nil - } - - return "", nil, errors.BadRequest("%s is not a valid field name", name) + return fmt.Sprintf("%s.%s", ctx.cfg.TableName, trimmedName), nil, nil } func walkNaryExpr(n *tsl.TSLNode, ctx *walkContext) (string, []any, *errors.ServiceError) { diff --git a/pkg/db/sql_tsl_test.go b/pkg/db/sql_tsl_test.go index 047358ec..e21829b9 100644 --- a/pkg/db/sql_tsl_test.go +++ b/pkg/db/sql_tsl_test.go @@ -647,51 +647,22 @@ func TestResolveField_PlainField(t *testing.T) { } } -func TestTSLToSQL_ResolveRelated(t *testing.T) { - t.Run("related field resolved via callback", func(t *testing.T) { - RegisterTestingT(t) - tree, err := tsl.ParseTSL("creator.username = 'alice'") - Expect(err).ToNot(HaveOccurred()) - - sql, values, svcErr := TSLToSQL(tree, WalkConfig{ - TableName: "resources", - ResolveRelated: func(name string) (string, error) { - return "users.username", nil - }, - }) - Expect(svcErr).To(BeNil()) - Expect(sql).To(Equal("users.username = ?")) - Expect(values).To(ConsistOf("alice")) - }) - - t.Run("related field without callback rejected", func(t *testing.T) { - RegisterTestingT(t) - tree, err := tsl.ParseTSL("creator.username = 'alice'") - Expect(err).ToNot(HaveOccurred()) - - _, _, svcErr := TSLToSQL(tree, WalkConfig{TableName: "resources"}) - Expect(svcErr).ToNot(BeNil()) - }) - - t.Run("uppercase segment in related path rejected before ResolveRelated", func(t *testing.T) { - RegisterTestingT(t) - tree, err := tsl.ParseTSL("creator.UserName = 'alice'") - Expect(err).ToNot(HaveOccurred()) +func TestTSLToSQL_DottedFieldRejection(t *testing.T) { + tests := []struct { + name string + query string + }{ + {name: "dotted field with no known prefix is rejected", query: "creator.username = 'alice'"}, + {name: "unauthorized table name is rejected", query: "unauthorized_table.name = 'value'"}, + {name: "uppercase segment in dotted field is rejected", query: "creator.UserName = 'alice'"}, + } - called := false - _, _, svcErr := TSLToSQL(tree, WalkConfig{ - TableName: "resources", - ResolveRelated: func(name string) (string, error) { - called = true - return "users.username", nil - }, + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svcErr := walkExpectError(t, tt.query) + Expect(svcErr.Error()).To(ContainSubstring("is not a valid field name")) }) - if svcErr == nil { - t.Fatal("expected a service error but got nil") - } - Expect(called).To(BeFalse()) - Expect(svcErr.Error()).To(ContainSubstring("is not a valid field name")) - }) + } } func TestTSLToSQL_LogicalOperators(t *testing.T) { diff --git a/pkg/services/generic.go b/pkg/services/generic.go index efa48b6f..308b909e 100755 --- a/pkg/services/generic.go +++ b/pkg/services/generic.go @@ -3,9 +3,7 @@ package services import ( "context" e "errors" - "fmt" "reflect" - "strings" "github.com/yaacov/tree-search-language/v6/pkg/tsl" "gorm.io/gorm" @@ -41,10 +39,7 @@ type listContext struct { resourceList interface{} args *ListArguments pagingMeta *api.PagingMeta - joins map[string]dao.TableRelation - set map[string]bool resourceType string - groupBy []string } func (s *sqlGenericService) newListContext( @@ -117,12 +112,6 @@ func (s *sqlGenericService) List( type listBuilder func(*listContext, dao.GenericDao) (finished bool, err *errors.ServiceError) func (s *sqlGenericService) buildPreload(listCtx *listContext, d dao.GenericDao) (bool, *errors.ServiceError) { - listCtx.set = make(map[string]bool) - - for _, preload := range listCtx.args.Preloads { - listCtx.set[preload] = true - } - // preload each table only once; struct{} doesn't occupy any additional space for _, preload := range listCtx.args.Preloads { d.Preload(preload) } @@ -153,7 +142,6 @@ func (s *sqlGenericService) buildTenantScope(listCtx *listContext, d dao.Generic func (s *sqlGenericService) buildSearch(listCtx *listContext, d dao.GenericDao) (bool, *errors.ServiceError) { if listCtx.args.Search == "" { - s.addJoins(listCtx, d) return true, nil } @@ -169,61 +157,17 @@ func (s *sqlGenericService) buildSearch(listCtx *listContext, d dao.GenericDao) return false, errors.BadRequest("failed to parse search query: %s", err.Error()) } - if listCtx.joins == nil { - listCtx.joins = map[string]dao.TableRelation{} - } - sql, values, serviceErr := db.TSLToSQL(tslTree, db.WalkConfig{ TableName: d.GetTableName(), - ResolveRelated: func(name string) (string, error) { - parts := strings.Split(name, ".") - fieldName := parts[0] - if _, exists := listCtx.joins[fieldName]; !exists { - if relation, ok := d.GetTableRelation(fieldName); ok { - listCtx.joins[fieldName] = relation - } else { - return "", fmt.Errorf("%s is not a related resource of %s", - fieldName, listCtx.resourceType) - } - } - parts[0] = listCtx.joins[fieldName].ForeignTableName - return strings.Join(parts, "."), nil - }, }) if serviceErr != nil { return false, serviceErr } - s.addJoins(listCtx, d) d.Where(dao.NewWhere(sql, values)) return true, nil } -// JOIN the tables that appear in the search string -func (s *sqlGenericService) addJoins(listCtx *listContext, d dao.GenericDao) { - for _, r := range listCtx.joins { - if _, ok := listCtx.set[r.ForeignTableName]; ok { - // skip already included preloads - continue - } - sql := fmt.Sprintf( - "LEFT JOIN %s ON %s.%s = %s.%s AND %s.deleted_time IS NULL", - r.ForeignTableName, r.ForeignTableName, r.ForeignColumnName, r.TableName, r.ColumnName, r.ForeignTableName) - d.Joins(sql) - - listCtx.groupBy = append(listCtx.groupBy, r.ForeignTableName+".id") - listCtx.set[r.ForeignTableName] = true - } - if len(listCtx.joins) > 0 { - // Add base relation - listCtx.groupBy = append(listCtx.groupBy, d.GetTableName()+".id") - d.Group(strings.Join(listCtx.groupBy, ",")) - } - - // Reset list of joins and group by's - listCtx.joins = map[string]dao.TableRelation{} -} - func (s *sqlGenericService) loadList(listCtx *listContext, d dao.GenericDao) *errors.ServiceError { args := listCtx.args diff --git a/test/integration/search_field_mapping_test.go b/test/integration/search_field_mapping_test.go index 0afb9c48..6d741b4f 100644 --- a/test/integration/search_field_mapping_test.go +++ b/test/integration/search_field_mapping_test.go @@ -1087,7 +1087,7 @@ func TestSearchInvalidFieldReturnsError(t *testing.T) { Expect(bodyStr).To(ContainSubstring("HYPERFLEET-VAL-005")) } - // label.environment: no such column after Labels JOIN - undefined column (42703) + // label.environment: dotted field with no known prefix (labels./spec./status.conditions.) is rejected t.Run("label.environment undefined column", func(t *testing.T) { RegisterTestingT(t) search := openapi.SearchParams("label.environment = 'production'") @@ -1096,7 +1096,7 @@ func TestSearchInvalidFieldReturnsError(t *testing.T) { assertCleanBadRequest(t, resp.Body, resp.StatusCode()) }) - // label.id: id exists in both joined tables - ambiguous column (42702 + // label.id: dotted field with no known prefix is rejected t.Run("label.id ambiguous column", func(t *testing.T) { RegisterTestingT(t) search := openapi.SearchParams("label.id = 'foo'") @@ -1150,3 +1150,25 @@ func TestSearchTautologyRejected(t *testing.T) { Expect(body).NotTo(ContainSubstring("SQLSTATE")) Expect(body).To(ContainSubstring("HYPERFLEET-VAL-005")) } + +// TestSearchConditionsTableColumnRejected verifies "conditions.type=" is rejected +func TestSearchConditionsTableColumnRejected(t *testing.T) { + RegisterTestingT(t) + h, client := test.RegisterIntegration(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + search := openapi.SearchParams("conditions.type = 'Reconciled'") + params := &openapi.GetClustersParams{ + Search: &search, + } + resp, err := client.GetClustersWithResponse(ctx, params, test.WithAuthToken(ctx)) + + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(http.StatusBadRequest)) + body := string(resp.Body) + Expect(body).NotTo(ContainSubstring("pq:")) + Expect(body).NotTo(ContainSubstring("SQLSTATE")) + Expect(body).To(ContainSubstring("HYPERFLEET-VAL-005")) +} From 727e5bd30c561d7f85c5a4c81e5a95c96fdfe3af Mon Sep 17 00:00:00 2001 From: Dmitrii Andreev Date: Tue, 15 Sep 2026 16:07:34 -0500 Subject: [PATCH 08/41] HYPERFLEET-871 - feat: move write transactions from middleware to services --- AGENTS.md | 6 +- cmd/hyperfleet-api/container/container.go | 1 + .../container/container_test.go | 10 + cmd/hyperfleet-api/container/db.go | 7 + cmd/hyperfleet-api/container/services.go | 1 + cmd/hyperfleet-api/servecmd/api_server.go | 7 +- .../servecmd/api_server_test.go | 2 +- cmd/hyperfleet-api/servecmd/cmd.go | 1 - cmd/hyperfleet-api/server/routes.go | 2 +- cmd/hyperfleet-api/server/routes_test.go | 4 +- docs/database.md | 5 +- pkg/dao/CLAUDE.md | 10 +- pkg/dao/adapter_status.go | 5 - pkg/dao/resource.go | 6 - pkg/dao/resource_condition.go | 4 - pkg/dao/resource_label.go | 2 - pkg/db/CLAUDE.md | 39 +- pkg/db/context.go | 70 +-- pkg/db/db_context/db_context.go | 26 - pkg/db/db_session/default.go | 9 +- pkg/db/db_session/test.go | 6 +- pkg/db/errors.go | 26 + pkg/db/errors_test.go | 50 ++ pkg/db/internal/txcontext/txcontext.go | 31 ++ pkg/db/mocks/session_factory.go | 4 + pkg/db/session.go | 7 + pkg/db/transaction/transaction.go | 56 -- pkg/db/transaction_middleware.go | 101 ---- pkg/db/transaction_middleware_test.go | 249 --------- pkg/db/transactions.go | 73 ++- pkg/db/transactions_test.go | 239 ++++++++ pkg/middleware/request_timeout.go | 24 + pkg/middleware/request_timeout_test.go | 66 +++ pkg/services/CLAUDE.md | 1 + pkg/services/adapter_status.go | 82 +-- pkg/services/resource.go | 522 +++++++++--------- pkg/services/resource_test.go | 21 +- pkg/services/test_helpers_test.go | 17 + pkg/services/transaction.go | 26 + pkg/services/transaction_test.go | 231 ++++++++ pkg/services/util.go | 22 +- test/helper.go | 1 - test/integration/advisory_locks_test.go | 24 +- test/integration/tenancy_scope_test.go | 147 ++--- test/integration/tenancy_uniqueness_test.go | 44 +- test/integration/transaction_test.go | 273 ++++----- 46 files changed, 1282 insertions(+), 1278 deletions(-) delete mode 100755 pkg/db/db_context/db_context.go create mode 100644 pkg/db/errors.go create mode 100644 pkg/db/errors_test.go create mode 100644 pkg/db/internal/txcontext/txcontext.go delete mode 100755 pkg/db/transaction/transaction.go delete mode 100755 pkg/db/transaction_middleware.go delete mode 100644 pkg/db/transaction_middleware_test.go create mode 100644 pkg/db/transactions_test.go create mode 100644 pkg/middleware/request_timeout.go create mode 100644 pkg/middleware/request_timeout_test.go create mode 100644 pkg/services/transaction.go create mode 100644 pkg/services/transaction_test.go diff --git a/AGENTS.md b/AGENTS.md index 2ea3cc02..41e08df1 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ HyperFleet API is a **stateless REST API** serving as the pure CRUD data layer f - **Language**: Go 1.26+ with FIPS crypto (`CGO_ENABLED=1 GOEXPERIMENT=boringcrypto`) - **Database**: PostgreSQL 14.2 with GORM ORM - **API Spec**: TypeSpec -> `hyperfleet-api-spec` Go module -> oapi-codegen -> Go models -- **Architecture**: Container-based dependency injection, config-driven route registration, transaction-per-request middleware +- **Architecture**: Container-based dependency injection, config-driven route registration, service-owned mutation transactions ## Critical First Steps @@ -61,11 +61,11 @@ Quick feedback: `make verify-all`. Full pre-push: `make test-all`. ## Architecture Context -**Request flow**: Router -> Middleware (logging, auth, transaction) -> Handler -> Service -> DAO -> GORM -> PostgreSQL +**Request flow**: Router -> Middleware (logging, auth, timeout) -> Handler -> Service transaction -> DAO -> GORM -> PostgreSQL - **Startup wiring**: `servecmd.runServe` loads config -> `container.NewContainer(cfg, closer)` -> `BuildAPIServer(...)` -> `server.NewRouterFromConfig` + `server.NewAPIServer`. Shutdown uses `pkg/closer` (LIFO order): readiness probe -> health drain -> metrics drain -> API drain -> JWT close -> OTel flush -> DB pool close - **Entity routes are config-driven**: declared in `config.yaml` under `entities:`, registered at startup via `registry.LoadDescriptors()`, routes auto-generated by `RegisterEntityRoutes`. No per-entity Go code needed. -- **Transaction middleware** creates GORM transactions for write requests only (POST/PUT/PATCH/DELETE); reads skip for performance +- **Service transactions**: each mutation service opens one `db.TxRunner.Do` boundary and commits before returning success; reads skip explicit transactions for performance. Request middleware applies timeouts only. - **Status aggregation**: Service layer synthesizes `Available`, `Reconciled`, and `LastKnownReconciled` conditions from adapter reports - **Public routes** (`/openapi`, `/openapi.html`, metadata) bypass auth and schema validation; everything else is gated by both, auth outermost - **Container** (`cmd/hyperfleet-api/container`) lazily constructs and caches dependencies. Holds DAOs, services, schema validator, JWT handler - but does NOT own their lifecycle. Shutdown ordering is handled by `pkg/closer` in the composition root. diff --git a/cmd/hyperfleet-api/container/container.go b/cmd/hyperfleet-api/container/container.go index a0b0d7bc..a6b7e7ec 100644 --- a/cmd/hyperfleet-api/container/container.go +++ b/cmd/hyperfleet-api/container/container.go @@ -16,6 +16,7 @@ type Container struct { cfg *config.ApplicationConfig closer *closer.Closer sessionFactory db.SessionFactory + txRunner db.TxRunner resourceDao dao.ResourceDao resourceLabelDao dao.ResourceLabelDao diff --git a/cmd/hyperfleet-api/container/container_test.go b/cmd/hyperfleet-api/container/container_test.go index bb769e0b..f851bea1 100644 --- a/cmd/hyperfleet-api/container/container_test.go +++ b/cmd/hyperfleet-api/container/container_test.go @@ -59,10 +59,20 @@ func TestContainerConstructionIsLazy(t *testing.T) { Expect(c.resourceService).To(BeNil()) Expect(c.adapterStatusService).To(BeNil()) Expect(c.genericService).To(BeNil()) + Expect(c.txRunner).To(BeNil()) Expect(c.schemaValidator).To(BeNil()) Expect(c.jwtHandler).To(BeNil()) } +func TestContainerCachesTxRunner(t *testing.T) { + RegisterTestingT(t) + + c := newTestContainer(t) + first := c.TxRunner() + Expect(first).NotTo(BeNil()) + Expect(c.TxRunner()).To(BeIdenticalTo(first)) +} + func TestContainerDoesNotInitializeGlobalRegistry(t *testing.T) { RegisterTestingT(t) diff --git a/cmd/hyperfleet-api/container/db.go b/cmd/hyperfleet-api/container/db.go index 33f87125..abbfc1a3 100644 --- a/cmd/hyperfleet-api/container/db.go +++ b/cmd/hyperfleet-api/container/db.go @@ -26,3 +26,10 @@ func (c *Container) SessionFactory() db.SessionFactory { } return c.sessionFactory } + +func (c *Container) TxRunner() db.TxRunner { + if c.txRunner == nil { + c.txRunner = db.NewTxRunner(c.SessionFactory()) + } + return c.txRunner +} diff --git a/cmd/hyperfleet-api/container/services.go b/cmd/hyperfleet-api/container/services.go index e2c202f8..b3228e4e 100644 --- a/cmd/hyperfleet-api/container/services.go +++ b/cmd/hyperfleet-api/container/services.go @@ -12,6 +12,7 @@ func (c *Container) ResourceService() services.ResourceService { c.AdapterStatusDao(), c.ResourceConditionDao(), c.GenericService(), + c.TxRunner(), ) if err != nil { panic("failed to create resource service: " + err.Error()) diff --git a/cmd/hyperfleet-api/servecmd/api_server.go b/cmd/hyperfleet-api/servecmd/api_server.go index 8df0ec43..e11b1dd8 100644 --- a/cmd/hyperfleet-api/servecmd/api_server.go +++ b/cmd/hyperfleet-api/servecmd/api_server.go @@ -2,13 +2,11 @@ package servecmd import ( "fmt" - "net/http" "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/server" requestlogging "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/server/logging" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/auth" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/middleware" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" @@ -28,7 +26,6 @@ func BuildAPIServer( adapterStatusService services.AdapterStatusService, schemaValidator *validators.SchemaValidator, jwtHandler *auth.JWTHandler, - sessionFactory db.SessionFactory, ) (*server.APIServer, error) { mainMiddleware := []server.Middleware{logger.RequestIDMiddleware} if cfg.Tracing.Enabled { @@ -44,9 +41,7 @@ func BuildAPIServer( protectedAPIMiddleware := []server.Middleware{ middleware.SchemaValidationMiddleware(schemaValidator), - func(next http.Handler) http.Handler { - return db.TransactionMiddleware(next, sessionFactory, cfg.Database.Pool.RequestTimeout) - }, + middleware.RequestTimeoutMiddleware(cfg.Database.Pool.RequestTimeout), } var authMiddleware []server.Middleware diff --git a/cmd/hyperfleet-api/servecmd/api_server_test.go b/cmd/hyperfleet-api/servecmd/api_server_test.go index 3dcd2cae..be2c1db4 100644 --- a/cmd/hyperfleet-api/servecmd/api_server_test.go +++ b/cmd/hyperfleet-api/servecmd/api_server_test.go @@ -37,7 +37,7 @@ func TestBuildAPIServer_TenantMiddlewareWiredWhenEnabled(t *testing.T) { }, } - apiServer, err := BuildAPIServer(cfg, nil, nil, nil, nil, nil) + apiServer, err := BuildAPIServer(cfg, nil, nil, nil, nil) Expect(err).NotTo(HaveOccurred()) listener, err := apiServer.Listen() diff --git a/cmd/hyperfleet-api/servecmd/cmd.go b/cmd/hyperfleet-api/servecmd/cmd.go index c1df1381..34801177 100755 --- a/cmd/hyperfleet-api/servecmd/cmd.go +++ b/cmd/hyperfleet-api/servecmd/cmd.go @@ -133,7 +133,6 @@ func runServe(cmd *cobra.Command, args []string) (runErr error) { ctr.AdapterStatusService(), ctr.SchemaValidator(), ctr.JWTHandler(), - ctr.SessionFactory(), ) if err != nil { return fmt.Errorf("build API server: %w", err) diff --git a/cmd/hyperfleet-api/server/routes.go b/cmd/hyperfleet-api/server/routes.go index 0340edf0..146650b2 100755 --- a/cmd/hyperfleet-api/server/routes.go +++ b/cmd/hyperfleet-api/server/routes.go @@ -49,7 +49,7 @@ func NewRouterFromConfig( apiV1Router.HandleFunc("GET /openapi", openapiHandler.GetOpenAPI) // authMiddleware must be outermost so unauthenticated requests are rejected - // before protectedAPIMiddleware opens a DB transaction or runs schema validation. + // before protectedAPIMiddleware runs schema validation or reaches mutation services. protectedRouter := apiV1Router.Group() for _, mw := range authMiddleware { protectedRouter.Use(mw) diff --git a/cmd/hyperfleet-api/server/routes_test.go b/cmd/hyperfleet-api/server/routes_test.go index 32f4df1e..d006da44 100644 --- a/cmd/hyperfleet-api/server/routes_test.go +++ b/cmd/hyperfleet-api/server/routes_test.go @@ -11,7 +11,7 @@ import ( // TestNewRouterFromConfig_PublicVsProtectedMiddleware guards the middleware layering: // // - apiMiddleware (metrics, compress) runs for ALL API routes, public and protected. -// - protectedAPIMiddleware (schema validation, DB transaction) runs only for +// - protectedAPIMiddleware (schema validation and timeout) runs only for // protected routes, after auth passes. // - authMiddleware gates protected routes; unauthenticated requests never reach // protectedAPIMiddleware. @@ -101,7 +101,7 @@ func TestNewRouterFromConfig_PublicVsProtectedMiddleware(t *testing.T) { Expect(apiMiddlewareCalls).To(Equal(1), "apiMiddleware must run for protected routes") Expect(authMiddlewareCalls).To(Equal(1), "authMiddleware must run for protected routes") Expect(protectedAPIMiddlewareCalls).To(Equal(0), - "protectedAPIMiddleware (schema validation, DB transaction) must not run when auth rejects the request") + "protectedAPIMiddleware (schema validation and timeout) must not run when auth rejects the request") // Protected route, authorized - all middleware layers run. authorized = true diff --git a/docs/database.md b/docs/database.md index dbea6920..dd3cf37e 100644 --- a/docs/database.md +++ b/docs/database.md @@ -128,8 +128,9 @@ See [development.md](development.md) for detailed setup instructions. ## Transaction Strategy -- **Write operations** (POST/PUT/PATCH/DELETE) run inside a database transaction with automatic commit on success and rollback on error. +- **Mutation services** own one `db.TxRunner.Do` boundary for each complete write operation. The runner commits before success and rolls back on callback errors or panics; nested transaction execution is rejected. - **Read operations** (GET) run without a transaction for lower latency and reduced connection pool pressure. +- **Reconciliation metrics** are collected as explicit outcomes during the write and emitted only after the transaction commits. ### Pagination note @@ -145,7 +146,7 @@ The connection pool is configured via CLI flags: | `--db-max-idle-connections` | 10 | Maximum idle connections retained in the pool | | `--db-conn-max-lifetime` | 5m | Maximum time a connection can be reused before being closed | | `--db-conn-max-idle-time` | 1m | Maximum time a connection can sit idle before being closed | -| `--db-request-timeout` | 30s | Context deadline applied to each HTTP request's database transaction | +| `--db-request-timeout` | 30s | Context deadline applied to each HTTP request's database work | | `--db-conn-retry-attempts` | 10 | Retry attempts for initial database connection on startup | | `--db-conn-retry-interval` | 3s | Wait time between connection retry attempts | diff --git a/pkg/dao/CLAUDE.md b/pkg/dao/CLAUDE.md index 6b524dab..b7592f7b 100644 --- a/pkg/dao/CLAUDE.md +++ b/pkg/dao/CLAUDE.md @@ -18,15 +18,11 @@ Always get the GORM session from context — never create direct DB connections: ```go db := d.sessionFactory.New(ctx) ``` -This retrieves the transaction-aware session created by `TransactionMiddleware`. +This retrieves the transaction-aware session created by the service's `db.TxRunner.Do` callback. ## Error Handling -On any write error, mark the transaction for rollback: -```go -db.MarkForRollback(ctx, err) -``` -This is critical — without it, the middleware will commit a partially-failed transaction. +Return ordinary errors. The service transaction callback rolls back automatically when it returns an error. ## Patterns @@ -45,4 +41,4 @@ This is critical — without it, the middleware will commit a partially-failed t ## Related CLAUDE.md Files - `pkg/services/CLAUDE.md` — Service layer that consumes DAOs -- `pkg/db/CLAUDE.md` — SessionFactory and transaction middleware +- `pkg/db/CLAUDE.md` — SessionFactory and service-owned transactions diff --git a/pkg/dao/adapter_status.go b/pkg/dao/adapter_status.go index dfe8f351..b95ae8e9 100644 --- a/pkg/dao/adapter_status.go +++ b/pkg/dao/adapter_status.go @@ -49,7 +49,6 @@ func (d *sqlAdapterStatusDao) Create( ) (*api.AdapterStatus, error) { g2 := d.sessionFactory.New(ctx) if err := g2.Omit(clause.Associations).Create(adapterStatus).Error; err != nil { - db.MarkForRollback(ctx, err) return nil, err } return adapterStatus, nil @@ -78,7 +77,6 @@ func (d *sqlAdapterStatusDao) Upsert( "last_report_time": adapterStatus.LastReportTime, }) if updateResult.Error != nil { - db.MarkForRollback(ctx, updateResult.Error) return nil, updateResult.Error } @@ -91,7 +89,6 @@ func (d *sqlAdapterStatusDao) Upsert( createResult := g2.Omit(clause.Associations).Clauses(clause.OnConflict{DoNothing: true}).Create(adapterStatus) if createResult.Error != nil { - db.MarkForRollback(ctx, createResult.Error) return nil, createResult.Error } if createResult.RowsAffected > 0 { @@ -106,7 +103,6 @@ func (d *sqlAdapterStatusDao) Delete(ctx context.Context, id string) error { g2 := d.sessionFactory.New(ctx) adapterStatus := &api.AdapterStatus{Meta: api.Meta{ID: id}} if err := g2.Omit(clause.Associations).Delete(adapterStatus).Error; err != nil { - db.MarkForRollback(ctx, err) return err } return nil @@ -116,7 +112,6 @@ func (d *sqlAdapterStatusDao) DeleteByResource(ctx context.Context, resourceType g2 := d.sessionFactory.New(ctx) if err := g2.Where("resource_type = ? AND resource_id = ?", resourceType, resourceID). Delete(&api.AdapterStatus{}).Error; err != nil { - db.MarkForRollback(ctx, err) return err } return nil diff --git a/pkg/dao/resource.go b/pkg/dao/resource.go index 116ed489..c07f122d 100644 --- a/pkg/dao/resource.go +++ b/pkg/dao/resource.go @@ -84,7 +84,6 @@ func (d *sqlResourceDao) Create(ctx context.Context, resource *api.Resource) (*a } g2 := d.sessionFactory.New(ctx) if err := g2.Omit(clause.Associations).Create(resource).Error; err != nil { - db.MarkForRollback(ctx, err) return nil, err } return resource, nil @@ -93,7 +92,6 @@ func (d *sqlResourceDao) Create(ctx context.Context, resource *api.Resource) (*a func (d *sqlResourceDao) Save(ctx context.Context, resource *api.Resource) error { g2 := d.sessionFactory.New(ctx) if err := g2.Omit(clause.Associations).Save(resource).Error; err != nil { - db.MarkForRollback(ctx, err) return err } return nil @@ -103,7 +101,6 @@ func (d *sqlResourceDao) Delete(ctx context.Context, kind, id string) error { g2 := tenant.ScopeDB(d.sessionFactory.New(ctx), ctx) if err := g2.Omit(clause.Associations).Where("kind = ?", kind).Delete( &api.Resource{Meta: api.Meta{ID: id}}).Error; err != nil { - db.MarkForRollback(ctx, err) return err } return nil @@ -192,7 +189,6 @@ func (d *sqlResourceDao) ReplaceReferences( ) error { g2 := d.sessionFactory.New(ctx) if err := g2.Where("source_id = ?", sourceID).Delete(&api.ResourceReference{}).Error; err != nil { - db.MarkForRollback(ctx, err) return err } for i := range refs { @@ -200,7 +196,6 @@ func (d *sqlResourceDao) ReplaceReferences( } if len(refs) > 0 { if err := g2.Create(&refs).Error; err != nil { - db.MarkForRollback(ctx, err) return err } } @@ -237,7 +232,6 @@ func (d *sqlResourceDao) FindReferencers( func (d *sqlResourceDao) ClearTargetReferences(ctx context.Context, targetID string) error { g2 := d.sessionFactory.New(ctx) if err := g2.Where("target_id = ?", targetID).Delete(&api.ResourceReference{}).Error; err != nil { - db.MarkForRollback(ctx, err) return err } return nil diff --git a/pkg/dao/resource_condition.go b/pkg/dao/resource_condition.go index c1c0e827..4c735b5d 100644 --- a/pkg/dao/resource_condition.go +++ b/pkg/dao/resource_condition.go @@ -45,7 +45,6 @@ func (d *sqlResourceConditionDao) UpdateConditions( var existing []api.ResourceCondition if err := g2.Where("resource_id = ?", resourceID).Find(&existing).Error; err != nil { - db.MarkForRollback(ctx, err) return err } @@ -75,13 +74,11 @@ func (d *sqlResourceConditionDao) UpdateConditions( } if err := g2.Where("resource_id = ?", resourceID).Delete(&api.ResourceCondition{}).Error; err != nil { - db.MarkForRollback(ctx, err) return err } if len(conditions) > 0 { if err := g2.Create(&conditions).Error; err != nil { - db.MarkForRollback(ctx, err) return err } } @@ -92,7 +89,6 @@ func (d *sqlResourceConditionDao) UpdateConditions( func (d *sqlResourceConditionDao) DeleteByResource(ctx context.Context, resourceID string) error { g2 := d.sessionFactory.New(ctx) if err := g2.Where("resource_id = ?", resourceID).Delete(&api.ResourceCondition{}).Error; err != nil { - db.MarkForRollback(ctx, err) return err } return nil diff --git a/pkg/dao/resource_label.go b/pkg/dao/resource_label.go index 46b75fb7..4bf84ca9 100644 --- a/pkg/dao/resource_label.go +++ b/pkg/dao/resource_label.go @@ -29,7 +29,6 @@ func (d *sqlResourceLabelDao) ReplaceLabels( g2 := d.sessionFactory.New(ctx) if err := g2.Where("resource_id = ?", resourceID).Delete(&api.ResourceLabel{}).Error; err != nil { - db.MarkForRollback(ctx, err) return err } @@ -40,7 +39,6 @@ func (d *sqlResourceLabelDao) ReplaceLabels( rows[i].ResourceID = resourceID } if err := g2.Create(&rows).Error; err != nil { - db.MarkForRollback(ctx, err) return err } } diff --git a/pkg/db/CLAUDE.md b/pkg/db/CLAUDE.md index 9aac48f0..98b89db2 100644 --- a/pkg/db/CLAUDE.md +++ b/pkg/db/CLAUDE.md @@ -8,7 +8,7 @@ Reference: `session.go` type SessionFactory interface { Init(*config.DatabaseConfig) DirectDB() *sql.DB - New(ctx context.Context) *gorm.DB // Get transaction-aware DB session + New(ctx context.Context) *gorm.DB // Get active transaction or non-transactional DB session CheckConnection() error Close() error ResetDB() // For testing @@ -16,25 +16,29 @@ type SessionFactory interface { } ``` -Always use `New(ctx)` to get a GORM session — never create direct DB connections. This ensures participation in the request transaction. +Always use `New(ctx)` to get a GORM session — never create direct DB connections. When the context contains an active service-owned transaction, `New(ctx)` returns that transaction; otherwise, it returns a non-transactional session. -## Transaction Middleware +## Transactions -Reference: `transaction_middleware.go` +Reference: `transactions.go` -`TransactionMiddleware(next http.Handler, connection SessionFactory, requestTimeout time.Duration) http.Handler` +`TxRunner.Do(ctx, callback)` owns application write transactions. The container +constructs the runner from its session factory; services receive the runner and +own one public mutation boundary each. -**Transaction Strategy (Optimized for Performance)**: -- **Write operations** (POST/PUT/PATCH/DELETE): Create GORM transactions for ACID guarantees -- **Read operations** (GET): Skip transaction creation for performance +Concrete mutation services open one transaction around their complete atomic operation. +The callback receives the transaction context; return an error to roll back and +return nil to commit before the service returns success. Nested transactions are rejected. +Read-only services do not open transactions. `RequestTimeoutMiddleware` applies request +deadlines to both reads and writes without owning transactions. -**Write Request Flow**: -1. Applies request timeout context -2. Creates new context with `NewContext(ctx, connection)` — begins transaction -3. Extracts transaction ID via `db_context.TxID(ctx)` -4. Sets transaction ID in logger context for trace correlation -5. Defers `Resolve(txCtx)` for automatic rollback/commit -6. Passes modified request to next handler +**Mutation Flow**: +1. Request timeout middleware derives a deadline context. +2. The mutation service calls its injected `TxRunner.Do` dependency. +3. The runner begins one transaction and passes a derived transaction context to the callback. +4. Callback errors and panics roll back; a nil callback error commits before service success returns. +5. Nested transaction creation is rejected. Reusable work participates through + private helpers rather than calling another public mutation service. **Read Request Flow**: 1. Applies request timeout context @@ -44,9 +48,8 @@ Reference: `transaction_middleware.go` **Trade-off**: List operations (COUNT + SELECT) may show inconsistent pagination totals under concurrent deletes (cosmetic issue, low probability). -## MarkForRollback - -On any write error in DAOs, call `MarkForRollback(ctx, err)`. Without this, the middleware will commit a partially-failed transaction. +DAOs return ordinary errors and never decide whether to roll back. Always pass the +callback context to every DAO call, including reads and locking reads. ## Related CLAUDE.md Files diff --git a/pkg/db/context.go b/pkg/db/context.go index 63561e93..9fc37155 100755 --- a/pkg/db/context.go +++ b/pkg/db/context.go @@ -5,11 +5,7 @@ import ( "errors" "github.com/google/uuid" - "github.com/prometheus/client_golang/prometheus" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - dbContext "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/db_context" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/db_metrics" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/internal/txcontext" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) @@ -34,58 +30,6 @@ func (m advisoryLockMap) set(id string, lockType LockType, lock *AdvisoryLock) { m[m.key(id, lockType)] = lock } -// NewContext returns a new context with transaction stored in it. -// Upon error, the original context is still returned along with an error -func NewContext(ctx context.Context, connection SessionFactory) (context.Context, error) { - tx, err := newTransaction(ctx, connection) - if err != nil { - return ctx, err - } - - ctx = dbContext.WithTransaction(ctx, tx) - - return ctx, nil -} - -// Resolve commits or rolls back the transaction based on the rollback flag. -// Should only be called by TransactionMiddleware for write operations. -func Resolve(ctx context.Context) { - tx, ok := dbContext.Transaction(ctx) - if !ok { - logger.With(ctx, - "error_type", "missing_transaction", - "error", "no active transaction found in context", - ).Error("Transaction resolution failed: no active transaction in context") - return - } - - if tx.MarkedForRollback() { - if err := tx.Rollback(); err != nil { - logger.WithError(ctx, err).Error("Could not rollback transaction") - recordTransactionError("rollback", "rollback_failed") - return - } - logger.Info(ctx, "Rolled back transaction") - } else { - if err := tx.Commit(); err != nil { - logger.WithError(ctx, err).Error("Could not commit transaction") - recordTransactionError("commit", "commit_failed") - return - } - } -} - -// MarkForRollback flags the transaction stored in the context for rollback and logs whatever error caused the rollback -func MarkForRollback(ctx context.Context, err error) { - transaction, ok := dbContext.Transaction(ctx) - if !ok { - logger.Error(ctx, "failed to mark transaction for rollback: could not retrieve transaction from context") - return - } - transaction.SetRollbackFlag(true) - logger.WithError(ctx, err).Info("Marked transaction for rollback") -} - // NewAdvisoryLockContext returns a new context with AdvisoryLock stored in it. // Upon error, the original context is still returned along with an error. // @@ -99,7 +43,7 @@ func NewAdvisoryLockContext( ctx context.Context, connection SessionFactory, id string, lockType LockType, ) (context.Context, string, error) { // FAIL-FAST: Detect transaction created before advisory lock - if _, hasTransaction := dbContext.Transaction(ctx); hasTransaction { + if txcontext.Has(ctx) { return ctx, "", errors.New( "advisory lock cannot be acquired within an existing transaction.\n" + "This causes a race condition where lock is released before transaction commits.\n\n" + @@ -179,13 +123,3 @@ func Unlock(ctx context.Context, callerUUID string) { // service call and is intentionally not unlocked here } } - -// recordTransactionError records transaction commit/rollback failures. -func recordTransactionError(operation, errorType string) { - db_metrics.ErrorsMetric.With(prometheus.Labels{ - "operation": operation, - "error_type": errorType, - "component": "api", - "version": api.Version, - }).Inc() -} diff --git a/pkg/db/db_context/db_context.go b/pkg/db/db_context/db_context.go deleted file mode 100755 index 0ee3624b..00000000 --- a/pkg/db/db_context/db_context.go +++ /dev/null @@ -1,26 +0,0 @@ -// Package db_context dbContext provides a wrapper around db context handling to allow access to the db context without -// requiring importing the db package, thus avoiding cyclic imports -package db_context - -import ( - "context" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/transaction" -) - -type contextKey int - -const ( - transactionKey contextKey = iota -) - -// WithTransaction adds the transaction to the context and returns a new context -func WithTransaction(ctx context.Context, tx *transaction.Transaction) context.Context { - return context.WithValue(ctx, transactionKey, tx) -} - -// Transaction extracts the transaction value from the context -func Transaction(ctx context.Context) (tx *transaction.Transaction, ok bool) { - tx, ok = ctx.Value(transactionKey).(*transaction.Transaction) - return tx, ok -} diff --git a/pkg/db/db_session/default.go b/pkg/db/db_session/default.go index 90afd017..6a5462a7 100755 --- a/pkg/db/db_session/default.go +++ b/pkg/db/db_session/default.go @@ -14,8 +14,8 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/db_context" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/db_metrics" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/internal/txcontext" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) @@ -205,11 +205,8 @@ func (f *Default) NewListener(ctx context.Context, channel string, callback func // If a transaction exists in context, returns that transaction so DAO operations participate. // Otherwise returns a non-transactional session. func (f *Default) New(ctx context.Context) *gorm.DB { - if tx, ok := db_context.Transaction(ctx); ok { - if tx.DB == nil { - panic("transaction context contains nil DB handle - check newTransaction() implementation") - } - return tx.DB + if tx, ok := txcontext.Session(ctx); ok { + return tx } if f.g2 == nil { diff --git a/pkg/db/db_session/test.go b/pkg/db/db_session/test.go index ab4a8ca4..056636f0 100755 --- a/pkg/db/db_session/test.go +++ b/pkg/db/db_session/test.go @@ -13,7 +13,7 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/db_context" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/internal/txcontext" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) @@ -199,8 +199,8 @@ func (f *Test) New(ctx context.Context) *gorm.DB { f.wasDisconnected = false } - if tx, ok := db_context.Transaction(ctx); ok { - return tx.DB + if tx, ok := txcontext.Session(ctx); ok { + return tx } return f.g2.Session(&gorm.Session{ diff --git a/pkg/db/errors.go b/pkg/db/errors.go new file mode 100644 index 00000000..2ce53b9f --- /dev/null +++ b/pkg/db/errors.go @@ -0,0 +1,26 @@ +package db + +import ( + "errors" + "io" + "net" + + "github.com/lib/pq" +) + +// IsInvalidColumnError reports undefined or ambiguous SQL column errors. +func IsInvalidColumnError(err error) bool { + pqErr, ok := errors.AsType[*pq.Error](err) + return ok && (pqErr.Code == "42703" || pqErr.Code == "42702") +} + +// IsDBConnectionError reports infrastructure connection failures. +func IsDBConnectionError(err error) bool { + if err == nil { + return false + } + if netErr, ok := errors.AsType[*net.OpError](err); ok && netErr != nil { + return true + } + return errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) +} diff --git a/pkg/db/errors_test.go b/pkg/db/errors_test.go new file mode 100644 index 00000000..9aa6cd28 --- /dev/null +++ b/pkg/db/errors_test.go @@ -0,0 +1,50 @@ +package db + +import ( + "errors" + "fmt" + "io" + "net" + "syscall" + "testing" + + "github.com/lib/pq" +) + +func TestIsInvalidColumnError(t *testing.T) { + assertInvalidColumnError(t, "undefined column", &pq.Error{Code: "42703"}, true) + assertInvalidColumnError(t, "ambiguous column", &pq.Error{Code: "42702"}, true) + assertInvalidColumnError(t, "other database error", &pq.Error{Code: "23505"}, false) + assertInvalidColumnError(t, "ordinary error", errors.New("ordinary error"), false) +} + +func TestIsDBConnectionError(t *testing.T) { + assertDBConnectionError(t, "nil", nil, false) + assertDBConnectionError(t, "connection refused", &net.OpError{ + Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED, + }, true) + assertDBConnectionError(t, "EOF", io.EOF, true) + assertDBConnectionError(t, "unexpected EOF", io.ErrUnexpectedEOF, true) + assertDBConnectionError(t, "wrapped connection refused", fmt.Errorf( + "driver: %w", &net.OpError{Err: syscall.ECONNREFUSED}, + ), true) + assertDBConnectionError(t, "ordinary error", errors.New("ordinary error"), false) +} + +func assertInvalidColumnError(t *testing.T, name string, err error, want bool) { + t.Helper() + t.Run(name, func(t *testing.T) { + if got := IsInvalidColumnError(err); got != want { + t.Fatalf("IsInvalidColumnError() = %v, want %v", got, want) + } + }) +} + +func assertDBConnectionError(t *testing.T, name string, err error, want bool) { + t.Helper() + t.Run(name, func(t *testing.T) { + if got := IsDBConnectionError(err); got != want { + t.Fatalf("IsDBConnectionError() = %v, want %v", got, want) + } + }) +} diff --git a/pkg/db/internal/txcontext/txcontext.go b/pkg/db/internal/txcontext/txcontext.go new file mode 100644 index 00000000..e5a74ed7 --- /dev/null +++ b/pkg/db/internal/txcontext/txcontext.go @@ -0,0 +1,31 @@ +// Package txcontext carries the active transaction session between the runner +// and session factories. It is internal so application packages cannot manage +// transaction lifecycle directly. +package txcontext + +import ( + "context" + + "gorm.io/gorm" +) + +type contextKey struct{} + +// Attach returns a derived context with an immutable transaction session. A +// retained context continues to refer to the completed SQL transaction, whose +// operations fail with sql.ErrTxDone instead of using a new session. +func Attach(ctx context.Context, db *gorm.DB) context.Context { + return context.WithValue(ctx, contextKey{}, db) +} + +// Session returns the participating database session when ctx has a transaction. +func Session(ctx context.Context) (*gorm.DB, bool) { + db, ok := ctx.Value(contextKey{}).(*gorm.DB) + return db, ok +} + +// Has reports whether ctx belongs to a transaction, including a completed one. +func Has(ctx context.Context) bool { + _, ok := ctx.Value(contextKey{}).(*gorm.DB) + return ok +} diff --git a/pkg/db/mocks/session_factory.go b/pkg/db/mocks/session_factory.go index e9a92778..9f4f1d5d 100755 --- a/pkg/db/mocks/session_factory.go +++ b/pkg/db/mocks/session_factory.go @@ -11,6 +11,7 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/internal/txcontext" ) var _ db.SessionFactory = &MockSessionFactory{} @@ -56,6 +57,9 @@ func (m *MockSessionFactory) DirectDB() *sql.DB { } func (m *MockSessionFactory) New(ctx context.Context) *gorm.DB { + if tx, ok := txcontext.Session(ctx); ok { + return tx + } return m.gormDB.WithContext(ctx) } diff --git a/pkg/db/session.go b/pkg/db/session.go index 5d9dbbb9..d7c8c751 100755 --- a/pkg/db/session.go +++ b/pkg/db/session.go @@ -19,3 +19,10 @@ type SessionFactory interface { NewListener(ctx context.Context, channel string, callback func(id string)) GetAdvisoryLockTimeout() int } + +// TxRunner executes one callback inside a database transaction. +type TxRunner interface { + // Do commits only after callback returns nil. Nested executions are rejected; + // public mutation methods therefore remain the sole owners of their transaction. + Do(ctx context.Context, callback func(context.Context) error) error +} diff --git a/pkg/db/transaction/transaction.go b/pkg/db/transaction/transaction.go deleted file mode 100755 index e0e2fee1..00000000 --- a/pkg/db/transaction/transaction.go +++ /dev/null @@ -1,56 +0,0 @@ -package transaction - -import ( - "errors" - - "gorm.io/gorm" -) - -// By default do no roll back transaction. -// only perform rollback if explicitly set by g2.g2.MarkForRollback(ctx, err) -const defaultRollbackPolicy = false - -// Transaction represents a database transaction. -// Contains GORM transaction to ensure all DAO operations participate in the transaction. -type Transaction struct { - DB *gorm.DB - rollbackFlag bool -} - -// BuildWithGORM creates a new transaction object with GORM transaction. -func BuildWithGORM(db *gorm.DB) *Transaction { - return &Transaction{ - DB: db, - rollbackFlag: defaultRollbackPolicy, - } -} - -// MarkedForRollback returns true if a transaction is flagged for rollback and false otherwise. -func (tx *Transaction) MarkedForRollback() bool { - return tx.rollbackFlag -} - -func (tx *Transaction) Commit() error { - if tx.DB == nil { - return errors.New("db: transaction hasn't been started yet") - } - - err := tx.DB.Commit().Error - tx.DB = nil - return err -} - -// Rollback ends the transaction by rolling back -func (tx *Transaction) Rollback() error { - if tx.DB == nil { - return errors.New("db: transaction hasn't been started yet") - } - - err := tx.DB.Rollback().Error - tx.DB = nil - return err -} - -func (tx *Transaction) SetRollbackFlag(flag bool) { - tx.rollbackFlag = flag -} diff --git a/pkg/db/transaction_middleware.go b/pkg/db/transaction_middleware.go deleted file mode 100755 index 31ff6a50..00000000 --- a/pkg/db/transaction_middleware.go +++ /dev/null @@ -1,101 +0,0 @@ -package db - -import ( - "context" - stderrors "errors" - "io" - "net" - "net/http" - "time" - - "github.com/lib/pq" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/response" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" -) - -// isWriteMethod reports whether method requires a database transaction. -// Write methods (POST, PUT, PATCH, DELETE) return true, others return false. -// Method must be uppercase (e.g., "POST", not "post"). -func isWriteMethod(method string) bool { - switch method { - case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: - return true - default: - return false - } -} - -// TransactionMiddleware creates a database transaction for write operations only. -// -// Write methods (POST/PUT/PATCH/DELETE) get GORM transactions for ACID guarantees. -// Read methods (GET) skip transaction creation for performance, reducing connection -// pool pressure and latency under high adapter polling load. -// -// Trade-off: List operations (COUNT + SELECT) may show inconsistent pagination -// totals under concurrent deletes, but this is an acceptable cosmetic issue. -// -// The requestTimeout is applied to all requests (read and write) to prevent -// queries from blocking indefinitely when the database is under pressure. -func TransactionMiddleware(next http.Handler, connection SessionFactory, requestTimeout time.Duration) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - if requestTimeout > 0 { - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, requestTimeout) - defer cancel() - } - - if isWriteMethod(r.Method) { - var err error - ctx, err = NewContext(ctx, connection) - if err != nil { - logger.WithError(ctx, err).Error("Could not create transaction") - var serviceErr *errors.ServiceError - if IsDBConnectionError(err) { - serviceErr = errors.ServiceUnavailable("Database connection unavailable") - } else { - // use default error to avoid exposing internals to users - serviceErr = errors.GeneralError("") - } - traceID, _ := logger.GetRequestID(ctx) - response.WriteProblemDetailsResponse(w, r, serviceErr.HTTPCode, serviceErr.AsProblemDetails(r.URL.Path, traceID)) - return - } - - *r = *r.WithContext(ctx) - defer func() { Resolve(ctx) }() - - next.ServeHTTP(w, r) - } else { - // Read operations: apply timeout context but skip transaction creation - *r = *r.WithContext(ctx) - next.ServeHTTP(w, r) - } - }) -} - -// IsInvalidColumnError reports whether err is an "undefined_column" (42703) -// or "ambiguous_column" (42702) error, both indicate an invalid field in a -// search or order query -func IsInvalidColumnError(err error) bool { - var pqErr *pq.Error - return stderrors.As(err, &pqErr) && (pqErr.Code == "42703" || pqErr.Code == "42702") -} - -// IsDBConnectionError indicates whether err is an infrastructure failure -// (network unreachable, connection refused, connection dropped) -func IsDBConnectionError(err error) bool { - if err == nil { - return false - } - var netErr *net.OpError - if stderrors.As(err, &netErr) { - return true - } - if stderrors.Is(err, io.EOF) || stderrors.Is(err, io.ErrUnexpectedEOF) { - return true - } - return false -} diff --git a/pkg/db/transaction_middleware_test.go b/pkg/db/transaction_middleware_test.go deleted file mode 100644 index d023c678..00000000 --- a/pkg/db/transaction_middleware_test.go +++ /dev/null @@ -1,249 +0,0 @@ -package db - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/http/httptest" - "syscall" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - . "github.com/onsi/gomega" - postgres "gorm.io/driver/postgres" - "gorm.io/gorm" - gormlogger "gorm.io/gorm/logger" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" -) - -type dbSessionFactory struct { - gormDB *gorm.DB - sqlDB *sql.DB -} - -func (f *dbSessionFactory) Init(*config.DatabaseConfig) {} -func (f *dbSessionFactory) New(_ context.Context) *gorm.DB { return f.gormDB } -func (f *dbSessionFactory) CheckConnection() error { return nil } -func (f *dbSessionFactory) Close() error { return nil } -func (f *dbSessionFactory) ResetDB() {} -func (f *dbSessionFactory) NewListener(_ context.Context, _ string, _ func(string)) {} -func (f *dbSessionFactory) GetAdvisoryLockTimeout() int { return 300 } -func (f *dbSessionFactory) DirectDB() *sql.DB { return f.sqlDB } - -func newDBSessionFactory(t *testing.T, setupMock func(sqlmock.Sqlmock)) *dbSessionFactory { - t.Helper() - db, mock, err := sqlmock.New() - if err != nil { - t.Fatalf("failed to create sqlmock: %v", err) - } - setupMock(mock) - - gormDB, err := gorm.Open(postgres.New(postgres.Config{Conn: db}), &gorm.Config{ - Logger: gormlogger.Discard, - }) - if err != nil { - t.Fatalf("failed to open GORM: %v", err) - } - - t.Cleanup(func() { - db.Close() - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatalf("unmet sqlmock expectations: %v", err) - } - }) - return &dbSessionFactory{gormDB: gormDB, sqlDB: db} -} - -func TestIsWriteMethod_StandardHTTPMethods(t *testing.T) { - tests := []struct { - name string - method string - expected bool - }{ - // Write methods - {"POST is write", http.MethodPost, true}, - {"PUT is write", http.MethodPut, true}, - {"PATCH is write", http.MethodPatch, true}, - {"DELETE is write", http.MethodDelete, true}, - - // Read methods - {"GET is read-only", http.MethodGet, false}, - {"HEAD is read-only", http.MethodHead, false}, - {"OPTIONS is read-only", http.MethodOptions, false}, - - // Edge cases - {"CONNECT is read-only (conservative)", http.MethodConnect, false}, - {"TRACE is read-only (conservative)", http.MethodTrace, false}, - {"Empty string is read-only", "", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isWriteMethod(tt.method) - if result != tt.expected { - t.Errorf("isWriteMethod(%q) = %v, want %v", tt.method, result, tt.expected) - } - }) - } -} - -func TestIsWriteMethod_NonStandardMethods(t *testing.T) { - // WebDAV and other non-standard methods - // These are treated as read-only (false) which is safe because: - // 1. hyperfleet-api router doesn't accept these methods (405 at routing layer) - // 2. If they somehow reach here, no transaction is conservative but acceptable - nonStandardMethods := []string{ - "PROPFIND", - "PROPPATCH", - "MKCOL", - "COPY", - "MOVE", - "LOCK", - "UNLOCK", - "CUSTOM", - } - - for _, method := range nonStandardMethods { - t.Run(method, func(t *testing.T) { - result := isWriteMethod(method) - if result != false { - t.Errorf("isWriteMethod(%q) = %v, want false (non-standard methods default to read-only)", method, result) - } - }) - } -} - -func TestTransactionMiddleware_DBUnavailable(t *testing.T) { - tests := []struct { - setupMock func(sqlmock.Sqlmock) - name string - expectedType string - expectedCode string - expectedDetail string - expectedStatus int - }{ - { - name: "DB unreachable (connection refused) - returns 503", - setupMock: func(mock sqlmock.Sqlmock) { - mock.ExpectBegin().WillReturnError( - &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}, - ) - }, - expectedStatus: http.StatusServiceUnavailable, - expectedType: "https://api.hyperfleet.io/errors/service-unavailable", - expectedCode: "HYPERFLEET-SVC-001", - expectedDetail: "Database connection unavailable", - }, - { - name: "DB unreachable (connection dropped) - returns 503", - setupMock: func(mock sqlmock.Sqlmock) { - mock.ExpectBegin().WillReturnError(io.EOF) - }, - expectedStatus: http.StatusServiceUnavailable, - expectedType: "https://api.hyperfleet.io/errors/service-unavailable", - expectedCode: "HYPERFLEET-SVC-001", - expectedDetail: "Database connection unavailable", - }, - { - name: "internal DB error (permission denied) - returns 500", - setupMock: func(mock sqlmock.Sqlmock) { - mock.ExpectBegin().WillReturnError(errors.New("permission denied for table resources")) - }, - expectedStatus: http.StatusInternalServerError, - expectedType: "https://api.hyperfleet.io/errors/internal-error", - expectedCode: "HYPERFLEET-INT-001", - expectedDetail: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - - factory := newDBSessionFactory(t, tt.setupMock) - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - }) - - handler := TransactionMiddleware(next, factory, 0) - req := httptest.NewRequest(http.MethodPost, "/api/hyperfleet/v1/clusters", nil) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - Expect(rec.Code).To(Equal(tt.expectedStatus)) - Expect(rec.Header().Get("Content-Type")).To(Equal("application/problem+json")) - - var body struct { - Code *string `json:"code"` - Detail string `json:"detail"` - Type string `json:"type"` - Status int `json:"status"` - } - Expect(json.NewDecoder(rec.Body).Decode(&body)).To(Succeed()) - Expect(body.Type).To(Equal(tt.expectedType)) - Expect(body.Status).To(Equal(tt.expectedStatus)) - Expect(body.Code).NotTo(BeNil()) - Expect(*body.Code).To(Equal(tt.expectedCode)) - if tt.expectedDetail != "" { - Expect(body.Detail).To(Equal(tt.expectedDetail)) - } - }) - } -} - -func TestIsDBConnectionError(t *testing.T) { - tests := []struct { - err error - name string - expected bool - }{ - { - name: "nil", - err: nil, - expected: false, - }, - { - name: "TCP connection refused", - err: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}, - expected: true, - }, - { - name: "TCP connection reset", - err: &net.OpError{Op: "read", Net: "tcp", Err: syscall.ECONNRESET}, - expected: true, - }, - { - name: "EOF", - err: io.EOF, - expected: true, - }, - { - name: "unexpected EOF", - err: io.ErrUnexpectedEOF, - expected: true, - }, - { - name: "wrapped TCP connection refused", - err: fmt.Errorf("driver: %w", &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}), - expected: true, - }, - { - name: "database constraint violation", - err: errors.New("violates unique constraint"), - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - Expect(IsDBConnectionError(tt.err)).To(Equal(tt.expected)) - }) - } -} diff --git a/pkg/db/transactions.go b/pkg/db/transactions.go index 58792d9b..813f0e5f 100755 --- a/pkg/db/transactions.go +++ b/pkg/db/transactions.go @@ -2,25 +2,72 @@ package db import ( "context" + "database/sql" + "errors" + "fmt" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/transaction" + "github.com/prometheus/client_golang/prometheus" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/db_metrics" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/internal/txcontext" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) -// newTransaction constructs a new Transaction object with GORM transaction. -// Creates GORM transaction so that DAO operations participate in the transaction. -func newTransaction(ctx context.Context, connection SessionFactory) (*transaction.Transaction, error) { - if connection == nil { - // This happens in non-integration tests - return nil, nil +// TransactionRunner executes callbacks inside database transactions. +type TransactionRunner struct { + connection SessionFactory +} + +// NewTxRunner creates the transaction dependency used by mutation services. +// Container construction guarantees connection is initialized. +func NewTxRunner(connection SessionFactory) *TransactionRunner { + return &TransactionRunner{connection: connection} +} + +// Do runs callback in a transaction scoped to a derived context. Returning an +// error rolls back; returning nil commits before this method returns. +func (r *TransactionRunner) Do(ctx context.Context, callback func(context.Context) error) error { + if txcontext.Has(ctx) { + return errors.New("db: nested transactions are not supported") } - // Get GORM session and start transaction - g2 := connection.New(ctx) - gormTx := g2.Begin() + gormTx := r.connection.New(ctx).Begin() if gormTx.Error != nil { - _ = gormTx.Rollback() // Best-effort cleanup - return nil, gormTx.Error + logger.WithError(ctx, gormTx.Error).Error("Could not begin transaction") + recordTransactionError("begin", "begin_failed") + return fmt.Errorf("db: begin transaction: %w", gormTx.Error) } + txCtx := txcontext.Attach(ctx, gormTx) + completed := false + defer func() { + if !completed { + rollbackErr := gormTx.Rollback().Error + if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { + logger.WithError(txCtx, rollbackErr).Error("Could not rollback transaction") + recordTransactionError("rollback", "rollback_failed") + } + } + }() + + if err := callback(txCtx); err != nil { + return fmt.Errorf("db: transaction callback: %w", err) + } + if err := gormTx.Commit().Error; err != nil { + logger.WithError(txCtx, err).Error("Could not commit transaction") + recordTransactionError("commit", "commit_failed") + return fmt.Errorf("db: commit transaction: %w", err) + } + completed = true + return nil +} - return transaction.BuildWithGORM(gormTx), nil +// recordTransactionError records transaction completion failures. +func recordTransactionError(operation, errorType string) { + db_metrics.ErrorsMetric.With(prometheus.Labels{ + "operation": operation, + "error_type": errorType, + "component": "api", + "version": api.Version, + }).Inc() } diff --git a/pkg/db/transactions_test.go b/pkg/db/transactions_test.go new file mode 100644 index 00000000..018d0fe7 --- /dev/null +++ b/pkg/db/transactions_test.go @@ -0,0 +1,239 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "runtime" + "sync" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + dto "github.com/prometheus/client_model/go" + "gorm.io/driver/postgres" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/db_metrics" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/internal/txcontext" +) + +type dbSessionFactory struct { + gormDB *gorm.DB + sqlDB *sql.DB +} + +func (f *dbSessionFactory) Init(*config.DatabaseConfig) {} +func (f *dbSessionFactory) New(_ context.Context) *gorm.DB { return f.gormDB } +func (f *dbSessionFactory) CheckConnection() error { return nil } +func (f *dbSessionFactory) Close() error { return nil } +func (f *dbSessionFactory) ResetDB() {} +func (f *dbSessionFactory) NewListener(_ context.Context, _ string, _ func(string)) {} +func (f *dbSessionFactory) GetAdvisoryLockTimeout() int { return 300 } +func (f *dbSessionFactory) DirectDB() *sql.DB { return f.sqlDB } + +func newDBSessionFactory(t *testing.T, setupMock func(sqlmock.Sqlmock)) *dbSessionFactory { + t.Helper() + sqlDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + setupMock(mock) + mock.ExpectClose() + + gormDB, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{ + Logger: gormlogger.Discard, + }) + if err != nil { + t.Fatalf("failed to open GORM: %v", err) + } + t.Cleanup(func() { + if err := sqlDB.Close(); err != nil { + t.Errorf("close sqlmock database: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet sqlmock expectations: %v", err) + } + }) + return &dbSessionFactory{gormDB: gormDB, sqlDB: sqlDB} +} + +func TestTxRunnerBeginFailure(t *testing.T) { + beginErr := errors.New("begin failed") + runner := NewTxRunner(newDBSessionFactory(t, func(mock sqlmock.Sqlmock) { + mock.ExpectBegin().WillReturnError(beginErr) + })) + + called := false + err := runner.Do(t.Context(), func(context.Context) error { + called = true + return nil + }) + if !errors.Is(err, beginErr) { + t.Fatalf("Do() error = %v, want %v", err, beginErr) + } + if got, want := err.Error(), "db: begin transaction: begin failed"; got != want { + t.Fatalf("Do() error = %q, want %q", got, want) + } + if called { + t.Fatal("callback ran after begin failed") + } +} + +func TestTxRunnerCommits(t *testing.T) { + runner := NewTxRunner(newDBSessionFactory(t, func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + mock.ExpectCommit() + })) + if err := runner.Do(t.Context(), func(context.Context) error { return nil }); err != nil { + t.Fatalf("Do() error = %v", err) + } +} + +func TestTxRunnerCommitFailure(t *testing.T) { + commitFailures := db_metrics.ErrorsMetric.WithLabelValues("commit", "commit_failed", "api", api.Version) + var before dto.Metric + if err := commitFailures.Write(&before); err != nil { + t.Fatalf("read commit failure counter: %v", err) + } + + commitErr := errors.New("commit failed") + runner := NewTxRunner(newDBSessionFactory(t, func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + mock.ExpectCommit().WillReturnError(commitErr) + })) + if err := runner.Do(t.Context(), func(context.Context) error { return nil }); !errors.Is(err, commitErr) { + t.Fatalf("Do() error = %v, want %v", err, commitErr) + } else if got, want := err.Error(), "db: commit transaction: commit failed"; got != want { + t.Fatalf("Do() error = %q, want %q", got, want) + } + + var after dto.Metric + if err := commitFailures.Write(&after); err != nil { + t.Fatalf("read commit failure counter: %v", err) + } + if got, want := after.GetCounter().GetValue(), before.GetCounter().GetValue()+1; got != want { + t.Fatalf("commit failure counter = %v, want %v", got, want) + } +} + +func TestTxRunnerCallbackErrorRollsBack(t *testing.T) { + callbackErr := errors.New("callback failed") + runner := NewTxRunner(newDBSessionFactory(t, func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + mock.ExpectRollback() + })) + if err := runner.Do(t.Context(), func(context.Context) error { return callbackErr }); !errors.Is(err, callbackErr) { + t.Fatalf("Do() error = %v, want %v", err, callbackErr) + } else if got, want := err.Error(), "db: transaction callback: callback failed"; got != want { + t.Fatalf("Do() error = %q, want %q", got, want) + } +} + +func TestTxRunnerPreservesCallbackErrorWhenRollbackFails(t *testing.T) { + callbackErr := errors.New("callback failed") + runner := NewTxRunner(newDBSessionFactory(t, func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + mock.ExpectRollback().WillReturnError(errors.New("rollback failed")) + })) + if err := runner.Do(t.Context(), func(context.Context) error { return callbackErr }); !errors.Is(err, callbackErr) { + t.Fatalf("Do() error = %v, want %v", err, callbackErr) + } +} + +func TestTxRunnerPanicRollsBack(t *testing.T) { + runner := NewTxRunner(newDBSessionFactory(t, func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + mock.ExpectRollback() + })) + defer func() { + if recovered := recover(); recovered != "panic value" { + t.Fatalf("panic = %v, want panic value", recovered) + } + }() + _ = runner.Do(t.Context(), func(context.Context) error { panic("panic value") }) +} + +func TestTxRunnerPropagatesContextAndRejectsNestedExecution(t *testing.T) { + type contextKey string + parent := context.WithValue(t.Context(), contextKey("key"), "value") + runner := NewTxRunner(newDBSessionFactory(t, func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + mock.ExpectRollback() + })) + + innerCalled := false + err := runner.Do(parent, func(txCtx context.Context) error { + if txCtx.Value(contextKey("key")) != "value" { + t.Fatal("transaction context did not inherit parent values") + } + if _, ok := txcontext.Session(txCtx); !ok { + t.Fatal("transaction context has no participating session") + } + return runner.Do(txCtx, func(context.Context) error { + innerCalled = true + return nil + }) + }) + if innerCalled { + t.Fatal("nested callback ran") + } + if err == nil || err.Error() != "db: transaction callback: db: nested transactions are not supported" { + t.Fatalf("nested transaction error = %v", err) + } +} + +func TestTxRunnerCancellationRollsBack(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + runner := NewTxRunner(newDBSessionFactory(t, func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + mock.ExpectRollback() + })) + err := runner.Do(ctx, func(txCtx context.Context) error { + cancel() + return txCtx.Err() + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Do() error = %v, want context.Canceled", err) + } +} + +func TestTxRunnerRetainedContextKeepsCompletedTransaction(t *testing.T) { + runner := NewTxRunner(newDBSessionFactory(t, func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + mock.ExpectCommit() + })) + var completed context.Context + if err := runner.Do(t.Context(), func(ctx context.Context) error { + completed = ctx + return nil + }); err != nil { + t.Fatalf("Do() error = %v", err) + } + transaction, ok := txcontext.Session(completed) + if !ok { + t.Fatal("Session() did not retain the transaction session") + } + if err := transaction.Exec("SELECT 1").Error; !errors.Is(err, sql.ErrTxDone) { + t.Fatalf("completed transaction error = %v, want %v", err, sql.ErrTxDone) + } +} + +func TestTxRunnerGoexitRollsBack(t *testing.T) { + runner := NewTxRunner(newDBSessionFactory(t, func(mock sqlmock.Sqlmock) { + mock.ExpectBegin() + mock.ExpectRollback() + })) + + var workers sync.WaitGroup + workers.Go(func() { + _ = runner.Do(t.Context(), func(context.Context) error { + runtime.Goexit() + return nil + }) + }) + workers.Wait() +} diff --git a/pkg/middleware/request_timeout.go b/pkg/middleware/request_timeout.go new file mode 100644 index 00000000..dea116ee --- /dev/null +++ b/pkg/middleware/request_timeout.go @@ -0,0 +1,24 @@ +package middleware + +import ( + "context" + "net/http" + "time" +) + +// RequestTimeoutMiddleware applies the configured request timeout to every +// protected request. Services own database transaction boundaries. +func RequestTimeoutMiddleware(requestTimeout time.Duration) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if requestTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, requestTimeout) + defer cancel() + } + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/pkg/middleware/request_timeout_test.go b/pkg/middleware/request_timeout_test.go new file mode 100644 index 00000000..788034e3 --- /dev/null +++ b/pkg/middleware/request_timeout_test.go @@ -0,0 +1,66 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestRequestTimeoutMiddlewareUsesDerivedRequestContext(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(t.Context()) + called := false + handler := RequestTimeoutMiddleware(time.Second)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + called = true + if _, ok := r.Context().Deadline(); !ok { + t.Fatal("forwarded request has no deadline") + } + })) + handler.ServeHTTP(httptest.NewRecorder(), request) + if !called { + t.Fatal("handler was not called") + } + if _, ok := request.Context().Deadline(); ok { + t.Fatal("original request context was modified") + } +} + +func TestRequestTimeoutMiddlewareWithoutTimeoutPreservesContext(t *testing.T) { + type contextKey string + ctx := context.WithValue(t.Context(), contextKey("key"), "value") + request := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + handler := RequestTimeoutMiddleware(0)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + if r.Context() != ctx { + t.Fatal("request context changed without a configured timeout") + } + })) + handler.ServeHTTP(httptest.NewRecorder(), request) +} + +func TestRequestTimeoutMiddlewareNegativeTimeoutPreservesContext(t *testing.T) { + ctx := t.Context() + request := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx) + handler := RequestTimeoutMiddleware(-time.Second)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + if r.Context() != ctx { + t.Fatal("request context changed with a negative timeout") + } + })) + handler.ServeHTTP(httptest.NewRecorder(), request) +} + +func TestRequestTimeoutMiddlewareCancelsDerivedContextAfterHandlerReturns(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(t.Context()) + var forwarded context.Context + handler := RequestTimeoutMiddleware(time.Second)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + forwarded = r.Context() + })) + + handler.ServeHTTP(httptest.NewRecorder(), request) + if forwarded == nil { + t.Fatal("handler did not receive a request context") + } + if err := forwarded.Err(); err != context.Canceled { + t.Fatalf("derived context error = %v, want %v", err, context.Canceled) + } +} diff --git a/pkg/services/CLAUDE.md b/pkg/services/CLAUDE.md index 98cb0f25..788fd2f5 100644 --- a/pkg/services/CLAUDE.md +++ b/pkg/services/CLAUDE.md @@ -16,6 +16,7 @@ func NewResourceService(resourceDao, resourceLabelDao, adapterStatusDao, resourc - All methods return `*errors.ServiceError` (from `pkg/errors/`), never stdlib `error` - Constructor injection: DAOs and config passed to `New*Service()` constructor +- Mutation services receive `db.TxRunner` and open one transaction with `Do` at their public boundary. Every dependent DAO call must use the callback context. Reconciliation outcomes are emitted only after `Do` succeeds. - Compile-time interface check: `var _ ResourceService = &sqlResourceService{}` - Mock generation: add `//go:generate mockgen` directive, then `make generate-mocks` diff --git a/pkg/services/adapter_status.go b/pkg/services/adapter_status.go index 8304a223..487edcdc 100644 --- a/pkg/services/adapter_status.go +++ b/pkg/services/adapter_status.go @@ -3,11 +3,8 @@ package services import ( "context" "encoding/json" - e "errors" "time" - "gorm.io/gorm" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" @@ -16,25 +13,13 @@ import ( //go:generate go tool -modfile=../../tools/go.mod mockgen -source=adapter_status.go -package=services -destination=adapter_status_mock.go type AdapterStatusService interface { - Get(ctx context.Context, id string) (*api.AdapterStatus, *errors.ServiceError) - Create(ctx context.Context, adapterStatus *api.AdapterStatus) (*api.AdapterStatus, *errors.ServiceError) - Upsert(ctx context.Context, adapterStatus *api.AdapterStatus) (*api.AdapterStatus, *errors.ServiceError) - Delete(ctx context.Context, id string) *errors.ServiceError - FindByResource( - ctx context.Context, resourceType, resourceID string, - ) (api.AdapterStatusList, *errors.ServiceError) FindByResourcePaginated( ctx context.Context, resourceType, resourceID string, listArgs *ListArguments, ) (api.AdapterStatusList, int64, *errors.ServiceError) - FindByResourceAndAdapter( - ctx context.Context, resourceType, resourceID, adapter string, - ) (*api.AdapterStatus, *errors.ServiceError) } func NewAdapterStatusService(adapterStatusDao dao.AdapterStatusDao) AdapterStatusService { - return &sqlAdapterStatusService{ - adapterStatusDao: adapterStatusDao, - } + return &sqlAdapterStatusService{adapterStatusDao: adapterStatusDao} } var _ AdapterStatusService = &sqlAdapterStatusService{} @@ -43,41 +28,6 @@ type sqlAdapterStatusService struct { adapterStatusDao dao.AdapterStatusDao } -func (s *sqlAdapterStatusService) Get(ctx context.Context, id string) (*api.AdapterStatus, *errors.ServiceError) { - adapterStatus, err := s.adapterStatusDao.Get(ctx, id) - if err != nil { - return nil, handleGetError("AdapterStatus", "id", id, err) - } - return adapterStatus, nil -} - -func (s *sqlAdapterStatusService) Create( - ctx context.Context, adapterStatus *api.AdapterStatus, -) (*api.AdapterStatus, *errors.ServiceError) { - adapterStatus, err := s.adapterStatusDao.Create(ctx, adapterStatus) - if err != nil { - return nil, handleCreateError("AdapterStatus", err) - } - return adapterStatus, nil -} - -func (s *sqlAdapterStatusService) Delete(ctx context.Context, id string) *errors.ServiceError { - if err := s.adapterStatusDao.Delete(ctx, id); err != nil { - return handleDeleteError("AdapterStatus", errors.GeneralError("Unable to delete adapter status: %s", err)) - } - return nil -} - -func (s *sqlAdapterStatusService) FindByResource( - ctx context.Context, resourceType, resourceID string, -) (api.AdapterStatusList, *errors.ServiceError) { - statuses, err := s.adapterStatusDao.FindByResource(ctx, resourceType, resourceID) - if err != nil { - return nil, errors.GeneralError("Unable to get adapter statuses: %s", err) - } - return statuses, nil -} - func (s *sqlAdapterStatusService) FindByResourcePaginated( ctx context.Context, resourceType, resourceID string, listArgs *ListArguments, ) (api.AdapterStatusList, int64, *errors.ServiceError) { @@ -92,36 +42,6 @@ func (s *sqlAdapterStatusService) FindByResourcePaginated( return statuses, total, nil } -func (s *sqlAdapterStatusService) FindByResourceAndAdapter( - ctx context.Context, resourceType, resourceID, adapter string, -) (*api.AdapterStatus, *errors.ServiceError) { - status, err := s.adapterStatusDao.FindByResourceAndAdapter(ctx, resourceType, resourceID, adapter) - if err != nil { - return nil, handleGetError("AdapterStatus", "adapter", adapter, err) - } - return status, nil -} - -func (s *sqlAdapterStatusService) Upsert( - ctx context.Context, adapterStatus *api.AdapterStatus, -) (*api.AdapterStatus, *errors.ServiceError) { - existing, findErr := s.adapterStatusDao.FindByResourceAndAdapter( - ctx, adapterStatus.ResourceType, adapterStatus.ResourceID, adapterStatus.Adapter, - ) - if findErr != nil { - if !e.Is(findErr, gorm.ErrRecordNotFound) { - return nil, handleGetError("AdapterStatus", "adapter", adapterStatus.Adapter, findErr) - } - existing = nil - } - setConditionTransitionTimes(adapterStatus, existing) - result, err := s.adapterStatusDao.Upsert(ctx, adapterStatus, existing) - if err != nil { - return nil, handleCreateError("AdapterStatus", err) - } - return result, nil -} - // setConditionTransitionTimes sets LastReportTime if unset and preserves condition // LastTransitionTime for any condition whose status hasn't changed since the last report // (Kubernetes condition semantic: LastTransitionTime only updates on status change). diff --git a/pkg/services/resource.go b/pkg/services/resource.go index 6636c642..e8bba99a 100644 --- a/pkg/services/resource.go +++ b/pkg/services/resource.go @@ -43,6 +43,7 @@ func NewResourceService( adapterStatusDao dao.AdapterStatusDao, resourceConditionDao dao.ResourceConditionDao, generic GenericService, + transactionRunner db.TxRunner, ) (ResourceService, error) { mappers, err := buildConditionMappers(registry.All()) if err != nil { @@ -54,6 +55,7 @@ func NewResourceService( adapterStatusDao: adapterStatusDao, resourceConditionDao: resourceConditionDao, generic: generic, + txRunner: transactionRunner, conditionMappers: mappers, }, nil } @@ -80,6 +82,7 @@ type sqlResourceService struct { adapterStatusDao dao.AdapterStatusDao resourceConditionDao dao.ResourceConditionDao generic GenericService + txRunner db.TxRunner conditionMappers map[string]*ConditionMapper // Indexed by Kind (e.g., "Cluster", "NodePool") } @@ -92,7 +95,7 @@ func (s *sqlResourceService) Get(ctx context.Context, kind, id string) (*api.Res trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_type", registry.MustGet(kind).Plural)) resource, err := s.resourceDao.Get(ctx, kind, id) if err != nil { - return nil, handleGetError(kind, "id", id, err) + return nil, handleGetError(kind, id, err) } return resource, nil } @@ -122,76 +125,84 @@ func (s *sqlResourceService) Create( return nil, svcErr } resource.Kind = kind - if svcErr := validateKind(kind); svcErr != nil { return nil, svcErr } - trace.SpanFromContext(ctx).SetAttributes( - attribute.String("hyperfleet.resource_type", registry.MustGet(kind).Plural), - ) - + trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_type", registry.MustGet(kind).Plural)) if svcErr := validateName(kind, resource.Name); svcErr != nil { return nil, svcErr } - // Lock parent row to serialize with concurrent deletes. - if ownerID := util.FromPtr(resource.OwnerID); ownerID != "" { - desc := registry.MustGet(kind) - parent, err := s.resourceDao.GetForUpdate(ctx, desc.ParentKind, ownerID) - if err != nil { - return nil, handleGetError(desc.ParentKind, "id", ownerID, err) - } - if parent.DeletedTime != nil { - return nil, errors.ConflictState("%s '%s' is marked for deletion", desc.ParentKind, ownerID) + var result *api.Resource + var outcome *reconciliationOutcome + err := s.txRunner.Do(ctx, func(ctx context.Context) error { + // Lock parent row to serialize with concurrent deletes. + if ownerID := util.FromPtr(resource.OwnerID); ownerID != "" { + desc := registry.MustGet(kind) + parent, err := s.resourceDao.GetForUpdate(ctx, desc.ParentKind, ownerID) + if err != nil { + return handleGetError(desc.ParentKind, ownerID, err) + } + if parent.DeletedTime != nil { + return errors.ConflictState("%s '%s' is marked for deletion", desc.ParentKind, ownerID) + } } - } - if svcErr := s.validateReferences(ctx, kind, refs); svcErr != nil { - return nil, svcErr - } + if svcErr := s.validateReferences(ctx, kind, refs); svcErr != nil { + return svcErr + } - username := actorFromContext(ctx) - if resource.CreatedBy == "" { - resource.CreatedBy = username - } - if resource.UpdatedBy == "" { - resource.UpdatedBy = username - } - resource.Tenancy = tenant.TenancyJSON(ctx) + username := actorFromContext(ctx) + if resource.CreatedBy == "" { + resource.CreatedBy = username + } + if resource.UpdatedBy == "" { + resource.UpdatedBy = username + } + resource.Tenancy = tenant.TenancyJSON(ctx) - resource, err := s.resourceDao.Create(ctx, resource) - if err != nil { - return nil, handleCreateError(kind, err) - } - trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_id", resource.ID)) + created, err := s.resourceDao.Create(ctx, resource) + if err != nil { + return handleCreateError(kind, err) + } + result = created + trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_id", created.ID)) - if len(resource.Labels) > 0 { - if labelErr := s.resourceLabelDao.ReplaceLabels(ctx, resource.ID, resource.Labels); labelErr != nil { - return nil, handleCreateError(kind, labelErr) + if len(created.Labels) > 0 { + if labelErr := s.resourceLabelDao.ReplaceLabels(ctx, created.ID, created.Labels); labelErr != nil { + return handleCreateError(kind, labelErr) + } } - } - // Persist references after the resource row exists (FK requires source_id). - if len(refs) > 0 { - refRows := convertRefs(kind, resource.ID, refs) - if refErr := s.resourceDao.ReplaceReferences(ctx, resource.ID, refRows); refErr != nil { - return nil, errors.GeneralError("failed to save references: %s", refErr) + // Persist references after the resource row exists (FK requires source_id). + if len(refs) > 0 { + refRows := convertRefs(kind, created.ID, refs) + if refErr := s.resourceDao.ReplaceReferences(ctx, created.ID, refRows); refErr != nil { + return errors.GeneralError("failed to save references: %s", refErr) + } + created.References = refRows } - resource.References = refRows - } - // Initialize conditions for entities with required adapters, matching the - // old ClusterService/NodePoolService behavior. Without this, newly created - // resources have no conditions rows, making them invisible to reconciliation - // metrics (INNER JOIN resource_conditions) and status search queries. - desc := registry.MustGet(kind) - if len(desc.RequiredAdapters) > 0 { - if svcErr := s.recomputeAndSaveResourceConditions(ctx, resource, nil); svcErr != nil { - return nil, svcErr + // Initialize conditions for entities with required adapters, matching the + // old ClusterService/NodePoolService behavior. + desc := registry.MustGet(kind) + if len(desc.RequiredAdapters) > 0 { + recomputed, svcErr := s.recomputeAndSaveResourceConditions(ctx, created, nil) + if svcErr != nil { + return svcErr + } + outcome = recomputed } - } - return resource, nil + return nil + }) + if svcErr := serviceErrorFromTransaction(err); svcErr != nil { + return nil, svcErr + } + if outcome != nil { + metrics.RecordReconciliationStarted(outcome.kind, outcome.isDelete) + } + return result, nil } func (s *sqlResourceService) Patch( @@ -205,74 +216,83 @@ func (s *sqlResourceService) Patch( return nil, svcErr } trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_type", registry.MustGet(kind).Plural)) - resource, err := s.resourceDao.GetForUpdate(ctx, kind, id) - if err != nil { - return nil, handleGetError(kind, "id", id, err) - } - if resource.DeletedTime != nil { - return nil, errors.ConflictState("%s '%s' is marked for deletion", kind, id) - } - - oldSpec := append([]byte(nil), resource.Spec...) - oldLabels := resource.Labels + var result *api.Resource + var outcome *reconciliationOutcome + err := s.txRunner.Do(ctx, func(ctx context.Context) error { + resource, err := s.resourceDao.GetForUpdate(ctx, kind, id) + if err != nil { + return handleGetError(kind, id, err) + } - if applyErr := applyResourcePatch(resource, patch); applyErr != nil { - return nil, errors.Validation("Invalid patch data: %v", applyErr) - } + if resource.DeletedTime != nil { + return errors.ConflictState("%s '%s' is marked for deletion", kind, id) + } - specChanged := !jsonBytesEqual(oldSpec, resource.Spec) - labelsChanged := !labelsEqual(oldLabels, resource.Labels) - refsChanged := patch.References != nil + oldSpec := append([]byte(nil), resource.Spec...) + oldLabels := resource.Labels - // Validate and persist references when the patch includes them (nil = skip, {} = clear). - if refsChanged { - if svcErr := s.validateReferences(ctx, kind, patch.References); svcErr != nil { - return nil, svcErr + if applyErr := applyResourcePatch(resource, patch); applyErr != nil { + return errors.Validation("Invalid patch data: %v", applyErr) } - refRows := convertRefs(kind, resource.ID, patch.References) - if refErr := s.resourceDao.ReplaceReferences( - ctx, resource.ID, refRows, - ); refErr != nil { - return nil, errors.GeneralError("failed to save references: %s", refErr) - } - resource.References = refRows - } - if !specChanged && !labelsChanged && !refsChanged { - return resource, nil - } + specChanged := !jsonBytesEqual(oldSpec, resource.Spec) + labelsChanged := !labelsEqual(oldLabels, resource.Labels) + refsChanged := patch.References != nil + + // Validate and persist references when the patch includes them (nil = skip, {} = clear). + if refsChanged { + if svcErr := s.validateReferences(ctx, kind, patch.References); svcErr != nil { + return svcErr + } + refRows := convertRefs(kind, resource.ID, patch.References) + if refErr := s.resourceDao.ReplaceReferences(ctx, resource.ID, refRows); refErr != nil { + return errors.GeneralError("failed to save references: %s", refErr) + } + resource.References = refRows + } - resource.IncrementGeneration() - resource.UpdatedBy = actorFromContext(ctx) + if !specChanged && !labelsChanged && !refsChanged { + result = resource + return nil + } - if saveErr := s.resourceDao.Save(ctx, resource); saveErr != nil { - return nil, handleUpdateError(kind, saveErr) - } + resource.IncrementGeneration() + resource.UpdatedBy = actorFromContext(ctx) - if labelsChanged { - if labelErr := s.resourceLabelDao.ReplaceLabels(ctx, resource.ID, resource.Labels); labelErr != nil { - return nil, handleUpdateError(kind, labelErr) + if saveErr := s.resourceDao.Save(ctx, resource); saveErr != nil { + return handleUpdateError(kind, saveErr) } - } - // Recompute conditions after generation change - the Reconciled condition - // must flip to False when the new generation hasn't been observed by adapters yet. - // Only applies to entities with required adapters; zero-adapter entities have no - // conditions to track. - desc := registry.MustGet(kind) - if len(desc.RequiredAdapters) > 0 { - adapterStatuses, statusErr := s.adapterStatusDao.FindByResource(ctx, kind, resource.ID) - if statusErr != nil { - db.MarkForRollback(ctx, statusErr) - return nil, errors.GeneralError("failed to get adapter statuses for condition recompute: %s", statusErr) + if labelsChanged { + if labelErr := s.resourceLabelDao.ReplaceLabels(ctx, resource.ID, resource.Labels); labelErr != nil { + return handleUpdateError(kind, labelErr) + } } - if svcErr := s.recomputeAndSaveResourceConditions(ctx, resource, adapterStatuses); svcErr != nil { - return nil, svcErr + + // Recompute conditions after generation change. + desc := registry.MustGet(kind) + if len(desc.RequiredAdapters) > 0 { + adapterStatuses, statusErr := s.adapterStatusDao.FindByResource(ctx, kind, resource.ID) + if statusErr != nil { + return errors.GeneralError("failed to get adapter statuses for condition recompute: %s", statusErr) + } + recomputed, svcErr := s.recomputeAndSaveResourceConditions(ctx, resource, adapterStatuses) + if svcErr != nil { + return svcErr + } + outcome = recomputed } + result = resource + return nil + }) + if svcErr := serviceErrorFromTransaction(err); svcErr != nil { + return nil, svcErr } - - return resource, nil + if outcome != nil { + metrics.RecordReconciliationStarted(outcome.kind, outcome.isDelete) + } + return result, nil } // Resources with required adapters are soft-deleted; all others are hard-deleted. @@ -285,39 +305,53 @@ func (s *sqlResourceService) Delete(ctx context.Context, kind, id string) (*api. return nil, svcErr } trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_type", registry.MustGet(kind).Plural)) - resource, err := s.resourceDao.GetForUpdate(ctx, kind, id) - if err != nil { - return nil, handleSoftDeleteError(kind, err) - } - deletedBy := actorFromContext(ctx) - deletedAt := time.Now().UTC().Truncate(time.Microsecond) + var result *api.Resource + var outcomes []reconciliationOutcome + err := s.txRunner.Do(ctx, func(ctx context.Context) error { + resource, err := s.resourceDao.GetForUpdate(ctx, kind, id) + if err != nil { + return handleSoftDeleteError(kind, err) + } + + deletedBy := actorFromContext(ctx) + deletedAt := time.Now().UTC().Truncate(time.Microsecond) - // Mark for deletion if not already soft-deleted - if resource.DeletedTime == nil { - resource.MarkDeleted(deletedBy, deletedAt) - resource.IncrementGeneration() - } + // Mark for deletion if not already soft-deleted. + if resource.DeletedTime == nil { + resource.MarkDeleted(deletedBy, deletedAt) + resource.IncrementGeneration() + } - if svcErr := s.deleteResourceTree(ctx, resource, deletedBy, deletedAt); svcErr != nil { - db.MarkForRollback(ctx, svcErr) + deleteOutcomes, svcErr := s.deleteResourceTree(ctx, resource, deletedBy, deletedAt) + if svcErr != nil { + return svcErr + } + outcomes = append(outcomes, deleteOutcomes...) + result = resource + return nil + }) + if svcErr := serviceErrorFromTransaction(err); svcErr != nil { return nil, svcErr } - - return resource, nil + for _, outcome := range outcomes { + metrics.RecordReconciliationStarted(outcome.kind, outcome.isDelete) + } + return result, nil } // deleteResourceTree enforces child delete policies then persists bottom-up. func (s *sqlResourceService) deleteResourceTree( ctx context.Context, resource *api.Resource, deletedBy string, deletedAt time.Time, -) *errors.ServiceError { +) ([]reconciliationOutcome, *errors.ServiceError) { + var outcomes []reconciliationOutcome children := registry.ChildrenOf(resource.Kind) for _, child := range children { if child.OnParentDelete == registry.OnParentDeleteRestrict { if svcErr := s.checkCanDelete(ctx, resource, child); svcErr != nil { - return svcErr + return nil, svcErr } } } @@ -326,7 +360,7 @@ func (s *sqlResourceService) deleteResourceTree( if child.OnParentDelete == registry.OnParentDeleteCascade { items, err := s.resourceDao.FindByKindAndOwnerForUpdate(ctx, child.Kind, resource.ID) if err != nil { - return errors.GeneralError( + return nil, errors.GeneralError( "Unable to find %s children for cascade delete: %s", child.Kind, err, ) } @@ -335,9 +369,11 @@ func (s *sqlResourceService) deleteResourceTree( item.MarkDeleted(deletedBy, deletedAt) item.IncrementGeneration() } - if svcErr := s.deleteResourceTree(ctx, item, deletedBy, deletedAt); svcErr != nil { - return svcErr + childOutcomes, svcErr := s.deleteResourceTree(ctx, item, deletedBy, deletedAt) + if svcErr != nil { + return nil, svcErr } + outcomes = append(outcomes, childOutcomes...) } } } @@ -345,7 +381,7 @@ func (s *sqlResourceService) deleteResourceTree( // Check if other resources reference this one before any deletion. referencers, refErr := s.resourceDao.FindReferencers(ctx, resource.ID) if refErr != nil { - return errors.GeneralError("failed to check references: %s", refErr) + return nil, errors.GeneralError("failed to check references: %s", refErr) } // List out all the references for a specific resource if len(referencers) > 0 { @@ -353,41 +389,44 @@ func (s *sqlResourceService) deleteResourceTree( for i, r := range referencers { names[i] = fmt.Sprintf("%s %q", r.Kind, r.Name) } - return errors.ConflictState( + return nil, errors.ConflictState( "cannot delete %s %q: referenced by %s — remove the reference(s) before deleting", resource.Kind, resource.Name, strings.Join(names, ", ")) } shouldSoftDelete, svcErr := s.shouldSoftDelete(ctx, resource, children) if svcErr != nil { - return svcErr + return nil, svcErr } if shouldSoftDelete { if saveErr := s.resourceDao.Save(ctx, resource); saveErr != nil { - return handleSoftDeleteError(resource.Kind, saveErr) + return nil, handleSoftDeleteError(resource.Kind, saveErr) } // Soft-delete should clear all resource references to satisfy the ON DELETE RESTRICT FK constraint. if err := s.resourceDao.ReplaceReferences(ctx, resource.ID, nil); err != nil { - return errors.GeneralError("failed to clear outbound references on soft-delete: %s", err) + return nil, errors.GeneralError("failed to clear outbound references on soft-delete: %s", err) } // Recompute conditions — generation incremented, Reconciled must flip to False. adapterStatuses, statusErr := s.adapterStatusDao.FindByResource(ctx, resource.Kind, resource.ID) if statusErr != nil { - db.MarkForRollback(ctx, statusErr) - return errors.GeneralError("failed to get adapter statuses for condition recompute: %s", statusErr) + return nil, errors.GeneralError("failed to get adapter statuses for condition recompute: %s", statusErr) } - if svcErr := s.recomputeAndSaveResourceConditions(ctx, resource, adapterStatuses); svcErr != nil { - return svcErr + outcome, svcErr := s.recomputeAndSaveResourceConditions(ctx, resource, adapterStatuses) + if svcErr != nil { + return nil, svcErr } - return nil + if outcome != nil { + outcomes = append(outcomes, *outcome) + } + return outcomes, nil } if err := s.resourceDao.Delete(ctx, resource.Kind, resource.ID); err != nil { - return handleDeleteError(resource.Kind, err) + return nil, handleDeleteError(resource.Kind, err) } - return nil + return outcomes, nil } // shouldSoftDelete determines whether a resource requires soft-deletion. @@ -452,7 +491,7 @@ func (s *sqlResourceService) GetByOwner( trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_type", registry.MustGet(kind).Plural)) resource, err := s.resourceDao.GetByOwner(ctx, kind, id, ownerID) if err != nil { - return nil, handleGetError(kind, "id", id, err) + return nil, handleGetError(kind, id, err) } return resource, nil } @@ -528,7 +567,7 @@ func (s *sqlResourceService) GetByID(ctx context.Context, id string) (*api.Resou trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_id", id)) resource, err := s.resourceDao.GetByID(ctx, id) if err != nil { - return nil, handleGetError("Resource", "id", id, err) + return nil, handleGetError("Resource", id, err) } desc, ok := registry.Get(resource.Kind) if ok { @@ -575,99 +614,76 @@ func (s *sqlResourceService) ProcessAdapterStatus( return nil, svcErr } trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_type", registry.MustGet(kind).Plural)) - - // Truncate to microsecond precision up front so the value used for staleness - // comparisons below matches exactly what Postgres persists and returns on - // read-back. Without this, a nanosecond-precision timestamp can round up - // during storage, making a later read of the same report compare as "after" - // the original in-memory value and incorrectly flip the staleness check. adapterStatus.LastReportTime = adapterStatus.LastReportTime.Truncate(time.Microsecond) - // Step 1: Acquire a row-level exclusive lock on the resource. Concurrent - // adapter status updates for the same resource are serialized here. - // GetForUpdate also preloads Conditions (needed for aggregation diff). - resource, err := s.resourceDao.GetForUpdate(ctx, kind, resourceID) - if err != nil { - return nil, handleGetError(kind, "id", resourceID, err) - } - - // Step 2: Fetch all existing adapter statuses for this resource. - // The existing status for the incoming adapter is found in-memory from - // this list — no additional DB call needed. - allStatuses, err := s.adapterStatusDao.FindByResource(ctx, kind, resourceID) - if err != nil { - return nil, errors.GeneralError("Failed to get adapter statuses: %s", err) - } - - existingStatus := findAdapterStatusInList(allStatuses, adapterStatus.Adapter) - - // Validate the incoming report: discard stale/future generations, zero - // observed times, subsequent Unknown Available, and missing mandatory - // conditions. Returns (nil, false, nil) when the update should be - // silently discarded (handler returns 204 No Content). - log := logger.With(ctx, "resource_type", kind, "resource_id", resourceID, - logger.FieldAdapter, adapterStatus.Adapter) - conditions, triggerAggregation, svcErr := validateAndClassifyAdapterStatus( - resource.Generation, adapterStatus, existingStatus, log, - ) - if svcErr != nil { - return nil, svcErr - } - if conditions == nil && !triggerAggregation { - return nil, nil - } - - // Step 3: Persist the adapter status. setConditionTransitionTimes preserves - // LastTransitionTime from the existing status when the condition status - // hasn't changed (Kubernetes-style semantics). - adapterStatus.ResourceType = kind - adapterStatus.ResourceID = resourceID - setConditionTransitionTimes(adapterStatus, existingStatus) - - upsertedStatus, err := s.adapterStatusDao.Upsert(ctx, adapterStatus, existingStatus) - if err != nil { - return nil, handleCreateError("AdapterStatus", err) - } + var result *api.AdapterStatus + var outcome *reconciliationOutcome + err := s.txRunner.Do(ctx, func(ctx context.Context) error { + resource, err := s.resourceDao.GetForUpdate(ctx, kind, resourceID) + if err != nil { + return handleGetError(kind, resourceID, err) + } + allStatuses, err := s.adapterStatusDao.FindByResource(ctx, kind, resourceID) + if err != nil { + return errors.GeneralError("Failed to get adapter statuses: %s", err) + } - // Build the post-upsert snapshot of all statuses. Using the pre-upsert - // list for hard-delete or aggregation would miss the just-written status. - updatedStatuses := replaceAdapterStatusInList(allStatuses, upsertedStatus) + existingStatus := findAdapterStatusInList(allStatuses, adapterStatus.Adapter) + log := logger.With(ctx, "resource_type", kind, "resource_id", resourceID, + logger.FieldAdapter, adapterStatus.Adapter) + conditions, triggerAggregation, svcErr := validateAndClassifyAdapterStatus( + resource.Generation, adapterStatus, existingStatus, log, + ) + if svcErr != nil { + return svcErr + } + if conditions == nil && !triggerAggregation { + return nil + } - // If the resource is soft-deleted, check whether all adapters have now - // reported Finalized=True — if so, hard-delete the resource. - if resource.DeletedTime != nil { - hardDeleted, hdErr := s.tryHardDeleteResource(ctx, resource, conditions, updatedStatuses) - if hdErr != nil { - return nil, hdErr + adapterStatus.ResourceType = kind + adapterStatus.ResourceID = resourceID + setConditionTransitionTimes(adapterStatus, existingStatus) + upsertedStatus, err := s.adapterStatusDao.Upsert(ctx, adapterStatus, existingStatus) + if err != nil { + return handleCreateError("AdapterStatus", err) } - if hardDeleted { - return upsertedStatus, nil + updatedStatuses := replaceAdapterStatusInList(allStatuses, upsertedStatus) + + if resource.DeletedTime != nil { + hardDeleted, hdErr := s.tryHardDeleteResource(ctx, resource, conditions, updatedStatuses) + if hdErr != nil { + return hdErr + } + if hardDeleted { + result = upsertedStatus + return nil + } } - } - // Step 4: Re-aggregate conditions from all adapter statuses and persist - // to the resource_conditions table. Runs when: - // 1. Available condition changed to True or False (not on Unknown or discarded updates), OR - // 2. A CEL condition mapper is configured AND (conditions or data changed from previous report) - // - // Rationale: mapper recompute is expensive (JSON marshal + MaskSensitiveFields + CEL eval), - // runs inside the GetForUpdate row-level lock, and most adapter reports are duplicates. - // Gating on actual changes reduces CPU waste and lock hold time (CWE-400 mitigation). - hasMapper := s.conditionMappers[resource.Kind] != nil - - // Inline statusChanged computation so jsonEqual is skipped when hasMapper=false, - // avoiding unnecessary JSON marshaling for entities without condition mappings. - if triggerAggregation || (hasMapper && (existingStatus == nil || - !jsonEqual(existingStatus.Conditions, adapterStatus.Conditions) || - !jsonEqual(existingStatus.Data, adapterStatus.Data))) { - if aggregateErr := s.recomputeAndSaveResourceConditions( - ctx, resource, updatedStatuses, - ); aggregateErr != nil { - return nil, aggregateErr + hasMapper := s.conditionMappers[resource.Kind] != nil + if triggerAggregation || (hasMapper && (existingStatus == nil || + !jsonEqual(existingStatus.Conditions, adapterStatus.Conditions) || + !jsonEqual(existingStatus.Data, adapterStatus.Data))) { + recomputed, aggregateErr := s.recomputeAndSaveResourceConditions( + ctx, resource, updatedStatuses, + ) + if aggregateErr != nil { + return aggregateErr + } + outcome = recomputed } - } - return upsertedStatus, nil + result = upsertedStatus + return nil + }) + if svcErr := serviceErrorFromTransaction(err); svcErr != nil { + return nil, svcErr + } + if outcome != nil { + metrics.RecordReconciliationStarted(outcome.kind, outcome.isDelete) + } + return result, nil } // recomputeAndSaveResourceConditions runs AggregateResourceStatus and persists @@ -677,7 +693,7 @@ func (s *sqlResourceService) recomputeAndSaveResourceConditions( ctx context.Context, resource *api.Resource, adapterStatuses api.AdapterStatusList, -) *errors.ServiceError { +) (*reconciliationOutcome, *errors.ServiceError) { desc := registry.MustGet(resource.Kind) // Convert the GORM association ([]ResourceCondition) to JSON so it can be @@ -689,11 +705,9 @@ func (s *sqlResourceService) recomputeAndSaveResourceConditions( var marshalErr error prevConditionsJSON, marshalErr = json.Marshal(resource.Conditions) if marshalErr != nil { - return errors.GeneralError("Failed to marshal previous conditions: %s", marshalErr) + return nil, errors.GeneralError("Failed to marshal previous conditions: %s", marshalErr) } } - - // Extract previous Reconciled status for metric emission below. prevReconciledStatus := extractPrevReconciledStatus(ctx, prevConditionsJSON) // During deletion, check if child resources still exist. The aggregation @@ -704,7 +718,7 @@ func (s *sqlResourceService) recomputeAndSaveResourceConditions( var err error hasChildResources, err = s.hasActiveChildren(ctx, resource) if err != nil { - return errors.GeneralError("Failed to check children for status aggregation: %s", err) + return nil, errors.GeneralError("Failed to check children for status aggregation: %s", err) } } @@ -746,10 +760,7 @@ func (s *sqlResourceService) recomputeAndSaveResourceConditions( PrevConditions: resource.Conditions, // Preserve timestamps from previous conditions }) if err != nil { - // Mark transaction for rollback - ensures adapter status update is retried - // in 10s instead of 30min delay that would occur with partial commit - db.MarkForRollback(ctx, fmt.Errorf("condition mapping failed for %s: %w", resource.Kind, err)) - return errors.GeneralError("Condition mapping failed: %s", err) + return nil, errors.GeneralError("Condition mapping failed: %s", err) } newConditions = append(newConditions, mappedConditions...) } @@ -757,28 +768,27 @@ func (s *sqlResourceService) recomputeAndSaveResourceConditions( // Compare via JSON to detect actual changes. newJSON, marshalErr := json.Marshal(newConditions) if marshalErr != nil { - return errors.GeneralError("Failed to marshal conditions: %s", marshalErr) + return nil, errors.GeneralError("Failed to marshal conditions: %s", marshalErr) } if jsonEqual(prevConditionsJSON, newJSON) { - return nil + return nil, nil } // Write to resource_conditions table (not JSONB on the resource row). - // MarkForRollback is handled by the DAO internally. if err := s.resourceConditionDao.UpdateConditions(ctx, resource.ID, newConditions); err != nil { - return errors.GeneralError("Failed to update resource conditions: %s", err) + return nil, errors.GeneralError("Failed to update resource conditions: %s", err) } // Update the in-memory resource so callers see the new conditions. resource.Conditions = newConditions - // Emit metric on Reconciled=False transition (reconciliation started). + // Return reconciliation work for the public mutation to emit after commit. if reconciled.Status == api.ConditionFalse && (prevReconciledStatus == nil || *prevReconciledStatus != api.ConditionFalse) { - metrics.RecordReconciliationStarted(resource.Kind, resource.DeletedTime != nil) + return &reconciliationOutcome{kind: resource.Kind, isDelete: resource.DeletedTime != nil}, nil } - return nil + return nil, nil } // tryHardDeleteResource checks whether all required adapters have reported @@ -977,22 +987,22 @@ func (s *sqlResourceService) ForceDelete(ctx context.Context, kind, id, reason s return svcErr } trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_type", registry.MustGet(kind).Plural)) + err := s.txRunner.Do(ctx, func(ctx context.Context) error { + resource, err := s.resourceDao.GetForUpdate(ctx, kind, id) + if err != nil { + return handleGetError(kind, id, err) + } + if resource.DeletedTime == nil { + return errors.ConflictState("%s '%s' is not in Finalizing state", kind, id) + } - resource, err := s.resourceDao.GetForUpdate(ctx, kind, id) - if err != nil { - return handleGetError(kind, "id", id, err) - } - - if resource.DeletedTime == nil { - return errors.ConflictState("%s '%s' is not in Finalizing state", kind, id) - } - - caller := actorFromContext(ctx) - if svcErr := s.forceDeleteResourceTree(ctx, resource, caller, reason); svcErr != nil { - db.MarkForRollback(ctx, svcErr) - return svcErr - } - return nil + caller := actorFromContext(ctx) + if svcErr := s.forceDeleteResourceTree(ctx, resource, caller, reason); svcErr != nil { + return svcErr + } + return nil + }) + return serviceErrorFromTransaction(err) } func (s *sqlResourceService) forceDeleteResourceTree( diff --git a/pkg/services/resource_test.go b/pkg/services/resource_test.go index 43805fa5..9369bb4d 100644 --- a/pkg/services/resource_test.go +++ b/pkg/services/resource_test.go @@ -284,7 +284,9 @@ var _ dao.ResourceConditionDao = &resourceConditionMock{} func newTestResourceService(mockDao *mockResourceDao) (ResourceService, *mockResourceDao, *resourceGenericMock) { generic := &resourceGenericMock{} svc, err := NewResourceService( - mockDao, newMockResourceLabelDao(), newMockAdapterStatusDao(), newResourceConditionMock(), generic, + mockDao, + newMockResourceLabelDao(), newMockAdapterStatusDao(), newResourceConditionMock(), generic, + &controlledTxRunner{}, ) if err != nil { panic("newTestResourceService: " + err.Error()) @@ -298,7 +300,7 @@ func newTestResourceServiceWithLabelDao( generic := &resourceGenericMock{} labelDao := newMockResourceLabelDao() svc, err := NewResourceService( - mockDao, labelDao, newMockAdapterStatusDao(), newResourceConditionMock(), generic, + mockDao, labelDao, newMockAdapterStatusDao(), newResourceConditionMock(), generic, &controlledTxRunner{}, ) if err != nil { panic("newTestResourceServiceWithLabelDao: " + err.Error()) @@ -312,7 +314,9 @@ func newTestResourceServiceWithAdapterStatus( asDao := newMockAdapterStatusDao() rcDao := newResourceConditionMock() generic := &resourceGenericMock{} - svc, err := NewResourceService(mockDao, newMockResourceLabelDao(), asDao, rcDao, generic) + svc, err := NewResourceService( + mockDao, newMockResourceLabelDao(), asDao, rcDao, generic, &controlledTxRunner{}, + ) if err != nil { panic("newTestResourceServiceWithAdapterStatus: " + err.Error()) } @@ -325,7 +329,9 @@ func newTestResourceServiceWithConditions( asDao := newMockAdapterStatusDao() rcDao := newResourceConditionMock() generic := &resourceGenericMock{} - svc, err := NewResourceService(mockDao, newMockResourceLabelDao(), asDao, rcDao, generic) + svc, err := NewResourceService( + mockDao, newMockResourceLabelDao(), asDao, rcDao, generic, &controlledTxRunner{}, + ) if err != nil { panic("newTestResourceServiceWithConditions: " + err.Error()) } @@ -2025,11 +2031,8 @@ func TestProcessAdapterStatus_ConditionMapperError_TriggersRollback(t *testing.T Expect(result).To(BeNil(), "result should be nil when error occurs") - // Note: In a real database transaction, returning an error from the service - // would trigger MarkForRollback() in the DAO layer, causing the transaction to rollback. - // This unit test uses mocks (no real transaction), so the mock DAO still has the data. - // The critical validation is: service returns error → handler marks transaction for rollback. - // For full rollback validation, see integration tests with testcontainers. + // The in-memory DAO does not model SQL rollback. Atomicity is covered by the + // production runner and PostgreSQL integration tests. } func TestProcessAdapterStatus_UnknownKind_Returns400(t *testing.T) { diff --git a/pkg/services/test_helpers_test.go b/pkg/services/test_helpers_test.go index b9bcac27..43e8a410 100644 --- a/pkg/services/test_helpers_test.go +++ b/pkg/services/test_helpers_test.go @@ -7,6 +7,23 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" ) +type controlledTxRunner struct { + afterCallbackErr error + callbackContext context.Context + calls int +} + +func (r *controlledTxRunner) Do(ctx context.Context, callback func(context.Context) error) error { + r.calls++ + if r.callbackContext != nil { + ctx = r.callbackContext + } + if err := callback(ctx); err != nil { + return err + } + return r.afterCallbackErr +} + type mockAdapterStatusDao struct { statuses map[string]*api.AdapterStatus findByResourceErr error diff --git a/pkg/services/transaction.go b/pkg/services/transaction.go new file mode 100644 index 00000000..9835d278 --- /dev/null +++ b/pkg/services/transaction.go @@ -0,0 +1,26 @@ +package services + +import ( + stderrors "errors" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" + serviceerrors "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" +) + +type reconciliationOutcome struct { + kind string + isDelete bool +} + +func serviceErrorFromTransaction(err error) *serviceerrors.ServiceError { + if err == nil { + return nil + } + if svcErr, ok := stderrors.AsType[*serviceerrors.ServiceError](err); ok { + return svcErr + } + if db.IsDBConnectionError(err) { + return serviceerrors.ServiceUnavailable("Database connection unavailable") + } + return serviceerrors.GeneralError("") +} diff --git a/pkg/services/transaction_test.go b/pkg/services/transaction_test.go new file mode 100644 index 00000000..36a64464 --- /dev/null +++ b/pkg/services/transaction_test.go @@ -0,0 +1,231 @@ +package services + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "strconv" + "testing" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + serviceerrors "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/metrics" +) + +func TestServiceErrorFromTransaction(t *testing.T) { + wantServiceError := serviceerrors.ConflictState("resource is busy") + tests := []struct { + err error + want *serviceerrors.ServiceError + name string + }{ + {name: "nil", err: nil, want: nil}, + { + name: "service error", + err: fmt.Errorf("transaction callback: %w", wantServiceError), + want: wantServiceError, + }, + { + name: "connection failure", + err: fmt.Errorf("commit: %w", &net.OpError{Op: "dial", Net: "tcp", Err: io.EOF}), + want: serviceerrors.ServiceUnavailable("Database connection unavailable"), + }, + {name: "ordinary failure", err: errors.New("commit failed"), want: serviceerrors.GeneralError("")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := serviceErrorFromTransaction(tt.err) + if tt.want == nil { + if got != nil { + t.Fatalf("serviceErrorFromTransaction() = %#v, want nil", got) + } + return + } + if got == nil { + t.Fatal("serviceErrorFromTransaction() = nil") + } + if got != tt.want && + (got.HTTPCode != tt.want.HTTPCode || + got.RFC9457Code != tt.want.RFC9457Code || + got.Reason != tt.want.Reason) { + t.Fatalf("serviceErrorFromTransaction() = %#v, want %#v", got, tt.want) + } + }) + } +} + +type contextCheckingResourceDAO struct { + *mockResourceDao + callbackContext context.Context +} + +func (d *contextCheckingResourceDAO) Create(ctx context.Context, resource *api.Resource) (*api.Resource, error) { + d.callbackContext = ctx + return d.mockResourceDao.Create(ctx, resource) +} + +func TestResourceServiceCreateUsesRunnerContextAndHandlesCommitFailure(t *testing.T) { + setupTestDescriptors() + type contextKey string + runner := &controlledTxRunner{ + callbackContext: context.WithValue(t.Context(), contextKey("transaction"), "callback"), + afterCallbackErr: errors.New("commit failed"), + } + resourceDao := &contextCheckingResourceDAO{mockResourceDao: newMockResourceDao()} + service, err := NewResourceService( + resourceDao, + newMockResourceLabelDao(), + newMockAdapterStatusDao(), + newResourceConditionMock(), + &resourceGenericMock{}, + runner, + ) + if err != nil { + t.Fatalf("NewResourceService() error = %v", err) + } + + resource := testResource("Channel", "ch-new", "new") + result, serviceErr := service.Create(t.Context(), "Channel", resource, nil) + if result != nil { + t.Fatalf("Create() result = %v, want nil after commit failure", result) + } + if serviceErr == nil { + t.Fatal("Create() returned nil service error after commit failure") + } + if runner.calls != 1 { + t.Fatalf("runner calls = %d, want 1", runner.calls) + } + if got := resourceDao.callbackContext.Value(contextKey("transaction")); got != "callback" { + t.Fatalf("DAO context value = %v, want callback", got) + } +} + +func TestResourceServiceCreateInvalidInputSkipsTransaction(t *testing.T) { + setupTestDescriptors() + runner := &controlledTxRunner{} + service, err := NewResourceService( + newMockResourceDao(), + newMockResourceLabelDao(), + newMockAdapterStatusDao(), + newResourceConditionMock(), + &resourceGenericMock{}, + runner, + ) + if err != nil { + t.Fatalf("NewResourceService() error = %v", err) + } + + _, serviceErr := service.Create(t.Context(), "Unknown", &api.Resource{Name: "new"}, nil) + if serviceErr == nil { + t.Fatal("Create() returned nil error for an unknown kind") + } + if runner.calls != 0 { + t.Fatalf("runner calls = %d, want 0", runner.calls) + } +} + +func TestResourceServiceEmitsReconciliationMetricOnlyAfterCommit(t *testing.T) { + setupAdapterStatusDescriptors() + metrics.ResetReconciliationMetrics() + t.Cleanup(metrics.ResetReconciliationMetrics) + + failedService := newTransactionTestResourceService(&controlledTxRunner{ + afterCallbackErr: errors.New("commit failed"), + }) + _, serviceErr := failedService.Create( + t.Context(), "TestResource", testResource("TestResource", "failed", "failed"), nil, + ) + if serviceErr == nil { + t.Fatal("Create() returned nil service error after commit failure") + } + if got := reconciliationMetricCount(t, "TestResource", false); got != 0 { + t.Fatalf("reconciliation metric count = %v, want 0 after commit failure", got) + } + + successService := newTransactionTestResourceService(&controlledTxRunner{}) + _, serviceErr = successService.Create( + t.Context(), "TestResource", testResource("TestResource", "success", "success"), nil, + ) + if serviceErr != nil { + t.Fatalf("Create() error = %v", serviceErr) + } + if got := reconciliationMetricCount(t, "TestResource", false); got != 1 { + t.Fatalf("reconciliation metric count = %v, want 1 after commit", got) + } +} + +func TestDeleteResourceTreeCollectsCascadeReconciliationOutcomes(t *testing.T) { + setupDescriptorsWithCascadeAndRequiredAdapters() + resourceDao := newMockResourceDao() + service := newTransactionTestResourceService(&controlledTxRunner{}).(*sqlResourceService) + service.resourceDao = resourceDao + + parent := testResource("Workspace", "workspace", "workspace") + child := testResourceWithOwner("Task", "task", "task", parent.ID) + resourceDao.addResource(parent) + resourceDao.addResource(child) + + now := parent.CreatedTime + parent.MarkDeleted("test", now) + parent.IncrementGeneration() + outcomes, serviceErr := service.deleteResourceTree(t.Context(), parent, "test", now) + if serviceErr != nil { + t.Fatalf("deleteResourceTree() error = %v", serviceErr) + } + if len(outcomes) != 2 { + t.Fatalf("outcome count = %d, want 2", len(outcomes)) + } + if outcomes[0].kind != "Task" || outcomes[1].kind != "Workspace" { + t.Fatalf("outcome kinds = %q, %q; want Task, Workspace", outcomes[0].kind, outcomes[1].kind) + } +} + +func newTransactionTestResourceService(runner *controlledTxRunner) ResourceService { + service, err := NewResourceService( + newMockResourceDao(), + newMockResourceLabelDao(), + newMockAdapterStatusDao(), + newResourceConditionMock(), + &resourceGenericMock{}, + runner, + ) + if err != nil { + panic("newTransactionTestResourceService: " + err.Error()) + } + return service +} + +func reconciliationMetricCount(t *testing.T, resourceType string, isDelete bool) float64 { + t.Helper() + families, err := prometheus.DefaultGatherer.Gather() + if err != nil { + t.Fatalf("gather reconciliation metrics: %v", err) + } + for _, family := range families { + if family.GetName() != "hyperfleet_api_reconciliation_started_total" { + continue + } + for _, metric := range family.Metric { + if metricLabelValue(metric, "resource_type") == resourceType && + metricLabelValue(metric, "is_delete") == strconv.FormatBool(isDelete) { + return metric.GetCounter().GetValue() + } + } + } + return 0 +} + +func metricLabelValue(metric *dto.Metric, name string) string { + for _, label := range metric.Label { + if label.GetName() == name { + return label.GetValue() + } + } + return "" +} diff --git a/pkg/services/util.go b/pkg/services/util.go index 3768d801..94c74455 100755 --- a/pkg/services/util.go +++ b/pkg/services/util.go @@ -34,30 +34,14 @@ func jsonEqual(a, b []byte) bool { return reflect.DeepEqual(va, vb) } -// Field names suspected to contain personally identifiable information -var piiFields = []string{ - "username", - "first_name", - "last_name", - "email", - "address", -} - -func handleGetError(resourceType, field string, value interface{}, err error) *errors.ServiceError { - // Sanitize errors of any personally identifiable information - for _, f := range piiFields { - if field == f { - value = "" - break - } - } +func handleGetError(resourceType, id string, err error) *errors.ServiceError { if e.Is(err, gorm.ErrRecordNotFound) { - return errors.NotFound("%s with %s='%v' not found", resourceType, field, value) + return errors.NotFound("%s with id='%s' not found", resourceType, id) } if db.IsDBConnectionError(err) { return errors.ServiceUnavailable("Database connection unavailable") } - return errors.GeneralError("Unable to find %s with %s='%v': %s", resourceType, field, value, err) + return errors.GeneralError("Unable to find %s with id='%s': %s", resourceType, id, err) } func handleCreateError(resourceType string, err error) *errors.ServiceError { diff --git a/test/helper.go b/test/helper.go index 8b610b65..a6fd23de 100755 --- a/test/helper.go +++ b/test/helper.go @@ -295,7 +295,6 @@ func (helper *Helper) startAPIServer() { helper.Container.AdapterStatusService(), helper.Container.SchemaValidator(), jwtHandler, - helper.DBFactory, ) if err != nil { abortSetup(ctx, helper.closer, err, "Unable to build Test API server") diff --git a/test/integration/advisory_locks_test.go b/test/integration/advisory_locks_test.go index 0b2631fb..8259de62 100644 --- a/test/integration/advisory_locks_test.go +++ b/test/integration/advisory_locks_test.go @@ -151,19 +151,17 @@ func TestAdvisoryLockFailsWithExistingTransaction(t *testing.T) { ctx := context.Background() - // Create transaction first - ctx, err := db.NewContext(ctx, h.DBFactory) - Expect(err).NotTo(HaveOccurred(), "Failed to create transaction context") - defer db.Resolve(ctx) - - // Try to acquire advisory lock (should fail) - _, lockID, err := db.NewAdvisoryLockContext(ctx, h.DBFactory, "test-resource", db.Migrations) - - // Should fail with clear error - Expect(err).To(HaveOccurred(), "Advisory lock should fail when transaction exists") - Expect(err.Error()).To(ContainSubstring("transaction"), "Error should mention transaction") - Expect(err.Error()).To(ContainSubstring("SELECT FOR UPDATE"), "Error should suggest SELECT FOR UPDATE") - Expect(lockID).To(BeEmpty(), "Lock ID should be empty on error") + runner := db.NewTxRunner(h.DBFactory) + err := runner.Do(ctx, func(txCtx context.Context) error { + // Try to acquire an advisory lock after the transaction has started. + _, lockID, err := db.NewAdvisoryLockContext(txCtx, h.DBFactory, "test-resource", db.Migrations) + Expect(err).To(HaveOccurred(), "Advisory lock should fail when transaction exists") + Expect(err.Error()).To(ContainSubstring("transaction"), "Error should mention transaction") + Expect(err.Error()).To(ContainSubstring("SELECT FOR UPDATE"), "Error should suggest SELECT FOR UPDATE") + Expect(lockID).To(BeEmpty(), "Lock ID should be empty on error") + return nil + }) + Expect(err).NotTo(HaveOccurred(), "rejected lock attempt should not abort the test transaction") } // TestLocksAndExpectedWaits validates the behavior of advisory locks: diff --git a/test/integration/tenancy_scope_test.go b/test/integration/tenancy_scope_test.go index fb40573f..e5072cc5 100644 --- a/test/integration/tenancy_scope_test.go +++ b/test/integration/tenancy_scope_test.go @@ -9,7 +9,6 @@ import ( "gorm.io/datatypes" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/tenant" @@ -72,37 +71,15 @@ func newTenancyNodePool(name, clusterID string) *api.Resource { } } -// createResourceInTx runs svc.Create for an arbitrary kind inside a write transaction. -func createResourceInTx( - base context.Context, sf db.SessionFactory, svc services.ResourceService, kind string, r *api.Resource, -) (*api.Resource, *errors.ServiceError) { - txCtx, err := db.NewContext(base, sf) - Expect(err).NotTo(HaveOccurred()) - defer db.Resolve(txCtx) - return svc.Create(txCtx, kind, r, nil) -} - -// patchInTx runs svc.Patch inside a write transaction. -func patchInTx( - base context.Context, sf db.SessionFactory, svc services.ResourceService, - kind, id string, patch *api.ResourcePatch, -) (*api.Resource, *errors.ServiceError) { - txCtx, err := db.NewContext(base, sf) - Expect(err).NotTo(HaveOccurred()) - defer db.Resolve(txCtx) - return svc.Patch(txCtx, kind, id, patch) -} - // TestResourceScopeCrossTenantAccess verifies Get, List, Patch, and Delete are // tenant-scoped: cross-tenant gets 404, same-tenant and system retain access. func TestResourceScopeCrossTenantAccess(t *testing.T) { h, _ := test.RegisterIntegration(t) svc := h.Container.ResourceService() - sf := h.Container.SessionFactory() ctxA, ctxB, ctxSystem := twoTenantContexts(h) - created, svcErr := createInTx(ctxA, sf, svc, newTenancyCluster("scope-cross-tenant")) + created, svcErr := svc.Create(ctxA, tenancyClusterKind, newTenancyCluster("scope-cross-tenant"), nil) Expect(svcErr).To(BeNil()) assertScopedVisibility(t, ctxA, ctxB, ctxSystem, created.ID, @@ -123,7 +100,7 @@ func TestResourceScopeCrossTenantAccess(t *testing.T) { // Same-tenant Patch succeeds (regression guard: if the Patch path changes to // use a different lookup, this catches same-tenant breakage that the // cross-tenant 404 assertion below would miss). - patched, svcErr := patchInTx(ctxA, sf, svc, tenancyClusterKind, created.ID, + patched, svcErr := svc.Patch(ctxA, tenancyClusterKind, created.ID, &api.ResourcePatch{Labels: map[string]string{"env": "staging"}}) Expect(svcErr).To(BeNil()) Expect(patched.ID).To(Equal(created.ID)) @@ -136,12 +113,13 @@ func TestResourceScopeCrossTenantAccess(t *testing.T) { name string }{ {func() *errors.ServiceError { - _, err := patchInTx(ctxB, sf, svc, tenancyClusterKind, created.ID, + _, err := svc.Patch(ctxB, tenancyClusterKind, created.ID, &api.ResourcePatch{Labels: map[string]string{"env": "test"}}) return err }, "Patch"}, {func() *errors.ServiceError { - return deleteInTx(ctxB, sf, svc, created.ID) + _, err := svc.Delete(ctxB, tenancyClusterKind, created.ID) + return err }, "Delete"}, } for _, tc := range crossTenantOps { @@ -154,7 +132,7 @@ func TestResourceScopeCrossTenantAccess(t *testing.T) { } // Same-tenant Delete succeeds. - svcErr = deleteInTx(ctxA, sf, svc, created.ID) + _, svcErr = svc.Delete(ctxA, tenancyClusterKind, created.ID) Expect(svcErr).To(BeNil()) } @@ -164,33 +142,26 @@ func TestResourceScopeCrossTenantAccess(t *testing.T) { func TestResourceScopeForceDeleteCrossTenant(t *testing.T) { h, _ := test.RegisterIntegration(t) svc := h.Container.ResourceService() - sf := h.Container.SessionFactory() ctxA, ctxB, _ := twoTenantContexts(h) - created, svcErr := createInTx(ctxA, sf, svc, newTenancyCluster("scope-force-delete")) + created, svcErr := svc.Create(ctxA, tenancyClusterKind, newTenancyCluster("scope-force-delete"), nil) Expect(svcErr).To(BeNil()) // Soft-delete first (ForceDelete requires Finalizing state). - svcErr = deleteInTx(ctxA, sf, svc, created.ID) + _, svcErr = svc.Delete(ctxA, tenancyClusterKind, created.ID) Expect(svcErr).To(BeNil()) t.Run("cross-tenant ForceDelete returns 404", func(t *testing.T) { RegisterTestingT(t) - txCtx, err := db.NewContext(ctxB, sf) - Expect(err).NotTo(HaveOccurred()) - defer db.Resolve(txCtx) - svcErr := svc.ForceDelete(txCtx, tenancyClusterKind, created.ID, "cross-tenant attempt") + svcErr := svc.ForceDelete(ctxB, tenancyClusterKind, created.ID, "cross-tenant attempt") Expect(svcErr).NotTo(BeNil()) Expect(svcErr.Is404()).To(BeTrue()) }) t.Run("same-tenant ForceDelete succeeds", func(t *testing.T) { RegisterTestingT(t) - txCtx, err := db.NewContext(ctxA, sf) - Expect(err).NotTo(HaveOccurred()) - defer db.Resolve(txCtx) - svcErr := svc.ForceDelete(txCtx, tenancyClusterKind, created.ID, "owner cleanup") + svcErr := svc.ForceDelete(ctxA, tenancyClusterKind, created.ID, "owner cleanup") Expect(svcErr).To(BeNil()) }) } @@ -200,14 +171,13 @@ func TestResourceScopeForceDeleteCrossTenant(t *testing.T) { func TestResourceScopeParentChildHierarchy(t *testing.T) { h, _ := test.RegisterIntegration(t) svc := h.Container.ResourceService() - sf := h.Container.SessionFactory() ctxA, ctxB, _ := twoTenantContexts(h) - cluster, svcErr := createInTx(ctxA, sf, svc, newTenancyCluster("scope-parent")) + cluster, svcErr := svc.Create(ctxA, tenancyClusterKind, newTenancyCluster("scope-parent"), nil) Expect(svcErr).To(BeNil()) - nodePool, svcErr := createResourceInTx(ctxA, sf, svc, scopeNodePoolKind, newTenancyNodePool("scope-child", cluster.ID)) + nodePool, svcErr := svc.Create(ctxA, scopeNodePoolKind, newTenancyNodePool("scope-child", cluster.ID), nil) Expect(svcErr).To(BeNil()) Expect(nodePool.Tenancy).To(MatchJSON(tenant.TenancyJSON(ctxA))) @@ -238,15 +208,14 @@ func TestResourceScopeParentChildHierarchy(t *testing.T) { func TestResourceScopeListTotalReflectsScopedCount(t *testing.T) { h, _ := test.RegisterIntegration(t) svc := h.Container.ResourceService() - sf := h.Container.SessionFactory() ctxA, ctxB, _ := twoTenantContexts(h) - _, svcErr := createInTx(ctxA, sf, svc, newTenancyCluster("scope-total-a1")) + _, svcErr := svc.Create(ctxA, tenancyClusterKind, newTenancyCluster("scope-total-a1"), nil) Expect(svcErr).To(BeNil()) - _, svcErr = createInTx(ctxA, sf, svc, newTenancyCluster("scope-total-a2")) + _, svcErr = svc.Create(ctxA, tenancyClusterKind, newTenancyCluster("scope-total-a2"), nil) Expect(svcErr).To(BeNil()) - _, svcErr = createInTx(ctxB, sf, svc, newTenancyCluster("scope-total-b1")) + _, svcErr = svc.Create(ctxB, tenancyClusterKind, newTenancyCluster("scope-total-b1"), nil) Expect(svcErr).To(BeNil()) _, paging, svcErr := svc.List(ctxA, tenancyClusterKind, services.NewListArguments()) @@ -263,7 +232,6 @@ func TestResourceScopeListTotalReflectsScopedCount(t *testing.T) { func TestResourceScopeAppliesToAllKinds(t *testing.T) { h, _ := test.RegisterIntegration(t) svc := h.Container.ResourceService() - sf := h.Container.SessionFactory() ctxA, ctxB, ctxSystem := twoTenantContexts(h) @@ -279,7 +247,7 @@ func TestResourceScopeAppliesToAllKinds(t *testing.T) { t.Run(k.kind, func(t *testing.T) { RegisterTestingT(t) - created, svcErr := createResourceInTx(ctxA, sf, svc, k.kind, k.resource) + created, svcErr := svc.Create(ctxA, k.kind, k.resource, nil) Expect(svcErr).To(BeNil()) assertScopedVisibility(t, ctxA, ctxB, ctxSystem, created.ID, @@ -299,11 +267,10 @@ func TestResourceScopeAppliesToAllKinds(t *testing.T) { func TestResourceScopeGetByID(t *testing.T) { h, _ := test.RegisterIntegration(t) svc := h.Container.ResourceService() - sf := h.Container.SessionFactory() ctxA, ctxB, ctxSystem := twoTenantContexts(h) - created, svcErr := createInTx(ctxA, sf, svc, newTenancyCluster("scope-get-by-id")) + created, svcErr := svc.Create(ctxA, tenancyClusterKind, newTenancyCluster("scope-get-by-id"), nil) Expect(svcErr).To(BeNil()) assertScopedVisibility(t, ctxA, ctxB, ctxSystem, created.ID, @@ -316,11 +283,10 @@ func TestResourceScopeGetByID(t *testing.T) { func TestResourceScopeListAll(t *testing.T) { h, _ := test.RegisterIntegration(t) svc := h.Container.ResourceService() - sf := h.Container.SessionFactory() ctxA, ctxB, ctxSystem := twoTenantContexts(h) - created, svcErr := createInTx(ctxA, sf, svc, newTenancyCluster("scope-list-all")) + created, svcErr := svc.Create(ctxA, tenancyClusterKind, newTenancyCluster("scope-list-all"), nil) Expect(svcErr).To(BeNil()) list, _, svcErr := svc.ListAll(ctxA, services.NewListArguments()) @@ -336,47 +302,30 @@ func TestResourceScopeListAll(t *testing.T) { Expect(idsOf(list)).NotTo(ContainElement(created.ID)) } -// deleteResourceInTx runs svc.Delete for an arbitrary kind inside a write transaction. -func deleteResourceInTx( - base context.Context, sf db.SessionFactory, svc services.ResourceService, kind, id string, -) *errors.ServiceError { - txCtx, err := db.NewContext(base, sf) - Expect(err).NotTo(HaveOccurred()) - defer db.Resolve(txCtx) - _, svcErr := svc.Delete(txCtx, kind, id) - return svcErr -} - // TestResourceScopeDaoFindAndExistsPaths verifies DAO methods not directly exposed // through the service layer (FindByKind, ExistsByOwner, etc.) are tenant-scoped. func TestResourceScopeDaoFindAndExistsPaths(t *testing.T) { h, _ := test.RegisterIntegration(t) svc := h.Container.ResourceService() - sf := h.Container.SessionFactory() resourceDao := h.Container.ResourceDao() ctxA, ctxB, ctxSystem := twoTenantContexts(h) - cluster, svcErr := createInTx(ctxA, sf, svc, newTenancyCluster("scope-dao-parent")) + cluster, svcErr := svc.Create(ctxA, tenancyClusterKind, newTenancyCluster("scope-dao-parent"), nil) Expect(svcErr).To(BeNil()) np := newTenancyNodePool("scope-dao-child", cluster.ID) - nodePool, svcErr := createResourceInTx(ctxA, sf, svc, scopeNodePoolKind, np) + nodePool, svcErr := svc.Create(ctxA, scopeNodePoolKind, np, nil) Expect(svcErr).To(BeNil()) registerRefTestDescriptors() - target, svcErr := createResourceInTx(ctxA, sf, svc, "RefTarget", - newRefTestResource("RefTarget", "scope-dao-reftarget-"+h.NewID())) + target, svcErr := svc.Create(ctxA, "RefTarget", + newRefTestResource("RefTarget", "scope-dao-reftarget-"+h.NewID()), nil) Expect(svcErr).To(BeNil()) refs := makeRefs("dep", struct{ id, kind string }{target.ID, "RefTarget"}) - source, svcErr := func() (*api.Resource, *errors.ServiceError) { - refTxCtx, err := db.NewContext(ctxA, sf) - Expect(err).NotTo(HaveOccurred()) - defer db.Resolve(refTxCtx) - return svc.Create(refTxCtx, "RefSource", - newRefTestResource("RefSource", "scope-dao-refsource-"+h.NewID()), refs) - }() + source, svcErr := svc.Create(ctxA, "RefSource", + newRefTestResource("RefSource", "scope-dao-refsource-"+h.NewID()), refs) Expect(svcErr).To(BeNil()) checkCases := []struct { @@ -432,10 +381,11 @@ func TestResourceScopeDaoFindAndExistsPaths(t *testing.T) { { name: "ExistsSoftDeletedByOwner", setup: func() { - deletedChild, svcErr := createResourceInTx( - ctxA, sf, svc, scopeNodePoolKind, newTenancyNodePool("scope-dao-deleted-child", cluster.ID)) + deletedChild, svcErr := svc.Create( + ctxA, scopeNodePoolKind, newTenancyNodePool("scope-dao-deleted-child", cluster.ID), nil) + Expect(svcErr).To(BeNil()) + _, svcErr = svc.Delete(ctxA, scopeNodePoolKind, deletedChild.ID) Expect(svcErr).To(BeNil()) - Expect(deleteResourceInTx(ctxA, sf, svc, scopeNodePoolKind, deletedChild.ID)).To(BeNil()) }, fetch: func(ctx context.Context) (bool, error) { return resourceDao.ExistsSoftDeletedByOwner(ctx, []string{scopeNodePoolKind}, cluster.ID) @@ -510,17 +460,16 @@ func idsOf(list api.ResourceList) []string { func TestResourceScopeTenancyImmutableThroughPatch(t *testing.T) { h, _ := test.RegisterIntegration(t) svc := h.Container.ResourceService() - sf := h.Container.SessionFactory() ctxA, _, _ := twoTenantContexts(h) - created, svcErr := createInTx(ctxA, sf, svc, newTenancyCluster("scope-immutable-tenancy")) + created, svcErr := svc.Create(ctxA, tenancyClusterKind, newTenancyCluster("scope-immutable-tenancy"), nil) Expect(svcErr).To(BeNil()) originalTenancy := created.Tenancy t.Run("spec patch preserves tenancy", func(t *testing.T) { RegisterTestingT(t) - patched, svcErr := patchInTx(ctxA, sf, svc, tenancyClusterKind, created.ID, + patched, svcErr := svc.Patch(ctxA, tenancyClusterKind, created.ID, &api.ResourcePatch{Spec: map[string]interface{}{"region": "eu-west1"}}) Expect(svcErr).To(BeNil()) Expect(patched.Tenancy).To(MatchJSON(originalTenancy)) @@ -528,7 +477,7 @@ func TestResourceScopeTenancyImmutableThroughPatch(t *testing.T) { t.Run("labels patch preserves tenancy", func(t *testing.T) { RegisterTestingT(t) - patched, svcErr := patchInTx(ctxA, sf, svc, tenancyClusterKind, created.ID, + patched, svcErr := svc.Patch(ctxA, tenancyClusterKind, created.ID, &api.ResourcePatch{Labels: map[string]string{"env": "staging"}}) Expect(svcErr).To(BeNil()) Expect(patched.Tenancy).To(MatchJSON(originalTenancy)) @@ -562,41 +511,31 @@ func newTestAdapterStatus(t *testing.T, adapter string, generation int32) *api.A func TestResourceScopeStatusSubResourceCrossTenant(t *testing.T) { h, _ := test.RegisterIntegration(t) svc := h.Container.ResourceService() - sf := h.Container.SessionFactory() ctxA, ctxB, ctxSystem := twoTenantContexts(h) - created, svcErr := createInTx(ctxA, sf, svc, newTenancyCluster("scope-status-parent")) + created, svcErr := svc.Create(ctxA, tenancyClusterKind, newTenancyCluster("scope-status-parent"), nil) Expect(svcErr).To(BeNil()) adapterStatus := newTestAdapterStatus(t, "test-adapter", created.Generation) t.Run("same-tenant status write succeeds", func(t *testing.T) { RegisterTestingT(t) - txCtx, err := db.NewContext(ctxA, sf) - Expect(err).NotTo(HaveOccurred()) - defer db.Resolve(txCtx) - result, svcErr := svc.ProcessAdapterStatus(txCtx, tenancyClusterKind, created.ID, adapterStatus) + result, svcErr := svc.ProcessAdapterStatus(ctxA, tenancyClusterKind, created.ID, adapterStatus) Expect(svcErr).To(BeNil()) Expect(result).NotTo(BeNil()) }) t.Run("system identity status write succeeds", func(t *testing.T) { RegisterTestingT(t) - txCtx, err := db.NewContext(ctxSystem, sf) - Expect(err).NotTo(HaveOccurred()) - defer db.Resolve(txCtx) - result, svcErr := svc.ProcessAdapterStatus(txCtx, tenancyClusterKind, created.ID, adapterStatus) + result, svcErr := svc.ProcessAdapterStatus(ctxSystem, tenancyClusterKind, created.ID, adapterStatus) Expect(svcErr).To(BeNil()) Expect(result).NotTo(BeNil()) }) t.Run("cross-tenant status write returns 404", func(t *testing.T) { RegisterTestingT(t) - txCtx, err := db.NewContext(ctxB, sf) - Expect(err).NotTo(HaveOccurred()) - defer db.Resolve(txCtx) - _, svcErr := svc.ProcessAdapterStatus(txCtx, tenancyClusterKind, created.ID, adapterStatus) + _, svcErr := svc.ProcessAdapterStatus(ctxB, tenancyClusterKind, created.ID, adapterStatus) Expect(svcErr).NotTo(BeNil()) Expect(svcErr.Is404()).To(BeTrue()) }) @@ -609,20 +548,19 @@ func TestResourceScopeStatusSubResourceCrossTenant(t *testing.T) { func TestResourceScopeSearchFiltersComposeWithTenancy(t *testing.T) { h, _ := test.RegisterIntegration(t) svc := h.Container.ResourceService() - sf := h.Container.SessionFactory() ctxA, ctxB, _ := twoTenantContexts(h) // Both tenants create a resource with the same label value. - clusterA, svcErr := createInTx(ctxA, sf, svc, newTenancyCluster("scope-search-a")) + clusterA, svcErr := svc.Create(ctxA, tenancyClusterKind, newTenancyCluster("scope-search-a"), nil) Expect(svcErr).To(BeNil()) - _, svcErr = patchInTx(ctxA, sf, svc, tenancyClusterKind, clusterA.ID, + _, svcErr = svc.Patch(ctxA, tenancyClusterKind, clusterA.ID, &api.ResourcePatch{Labels: map[string]string{"env": "prod"}}) Expect(svcErr).To(BeNil()) - clusterB, svcErr := createInTx(ctxB, sf, svc, newTenancyCluster("scope-search-b")) + clusterB, svcErr := svc.Create(ctxB, tenancyClusterKind, newTenancyCluster("scope-search-b"), nil) Expect(svcErr).To(BeNil()) - _, svcErr = patchInTx(ctxB, sf, svc, tenancyClusterKind, clusterB.ID, + _, svcErr = svc.Patch(ctxB, tenancyClusterKind, clusterB.ID, &api.ResourcePatch{Labels: map[string]string{"env": "prod"}}) Expect(svcErr).To(BeNil()) @@ -657,7 +595,6 @@ func TestResourceScopeSearchFiltersComposeWithTenancy(t *testing.T) { func TestResourceScopeContainmentHierarchy(t *testing.T) { h, _ := test.RegisterIntegration(t) svc := h.Container.ResourceService() - sf := h.Container.SessionFactory() orgID := "acme-" + h.NewID() projectAlpha := "alpha-" + h.NewID() @@ -672,10 +609,10 @@ func TestResourceScopeContainmentHierarchy(t *testing.T) { // Different org (sees neither). ctxOther := tenancyCtx(map[string]string{"org": otherOrg}) - clusterAlpha, svcErr := createInTx(ctxAlpha, sf, svc, newTenancyCluster("hierarchy-alpha")) + clusterAlpha, svcErr := svc.Create(ctxAlpha, tenancyClusterKind, newTenancyCluster("hierarchy-alpha"), nil) Expect(svcErr).To(BeNil()) - clusterBeta, svcErr := createInTx(ctxBeta, sf, svc, newTenancyCluster("hierarchy-beta")) + clusterBeta, svcErr := svc.Create(ctxBeta, tenancyClusterKind, newTenancyCluster("hierarchy-beta"), nil) Expect(svcErr).To(BeNil()) t.Run("org-scoped caller sees resources across both projects", func(t *testing.T) { @@ -719,7 +656,7 @@ func TestResourceScopeEmptyTenancyVisibility(t *testing.T) { // Create as tenant A (system identity can't create per HYPERFLEET-1472), then // overwrite tenancy to {} at the DB level to simulate a legacy/unscoped row. - created, svcErr := createInTx(ctxA, sf, svc, newTenancyCluster("scope-empty-tenancy")) + created, svcErr := svc.Create(ctxA, tenancyClusterKind, newTenancyCluster("scope-empty-tenancy"), nil) Expect(svcErr).To(BeNil()) g := sf.New(context.Background()) diff --git a/test/integration/tenancy_uniqueness_test.go b/test/integration/tenancy_uniqueness_test.go index 640e1cfa..3bd4dbf0 100644 --- a/test/integration/tenancy_uniqueness_test.go +++ b/test/integration/tenancy_uniqueness_test.go @@ -8,9 +8,6 @@ import ( . "github.com/onsi/gomega" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/tenant" "github.com/openshift-hyperfleet/hyperfleet-api/test" ) @@ -39,30 +36,6 @@ func newTenancyCluster(name string) *api.Resource { } } -// createInTx runs svc.Create inside a real write transaction, mirroring the transaction -// middleware. This gives the DAO a transaction to roll back on a conflict, so the assertions -// exercise the same write path as an HTTP request. The tenant-resolution middleware is not -// yet wired into the HTTP path, so base carries the tenant identity directly. -func createInTx( - base context.Context, sf db.SessionFactory, svc services.ResourceService, r *api.Resource, -) (*api.Resource, *errors.ServiceError) { - txCtx, err := db.NewContext(base, sf) - Expect(err).NotTo(HaveOccurred()) - defer db.Resolve(txCtx) - return svc.Create(txCtx, tenancyClusterKind, r, nil) -} - -// deleteInTx runs svc.Delete inside a real write transaction. -func deleteInTx( - base context.Context, sf db.SessionFactory, svc services.ResourceService, id string, -) *errors.ServiceError { - txCtx, err := db.NewContext(base, sf) - Expect(err).NotTo(HaveOccurred()) - defer db.Resolve(txCtx) - _, svcErr := svc.Delete(txCtx, tenancyClusterKind, id) - return svcErr -} - // TestClusterNameUniquenessIsTenantScoped verifies that root-resource name uniqueness is // scoped to the caller's tenancy: two tenants may own a cluster with the same name, while a // same-name create within a single tenancy still conflicts. It drives the service layer @@ -71,23 +44,22 @@ func deleteInTx( func TestClusterNameUniquenessIsTenantScoped(t *testing.T) { h, _ := test.RegisterIntegration(t) svc := h.Container.ResourceService() - sf := h.Container.SessionFactory() ctxAcme := tenancyCtx(map[string]string{tenancyOrgKey: "acme"}) ctxGlobex := tenancyCtx(map[string]string{tenancyOrgKey: "globex"}) // Tenant "acme" creates a cluster named "prod". The tenancy is stamped from the context. - acme, svcErr := createInTx(ctxAcme, sf, svc, newTenancyCluster(tenancyClusterName)) + acme, svcErr := svc.Create(ctxAcme, tenancyClusterKind, newTenancyCluster(tenancyClusterName), nil) Expect(svcErr).To(BeNil()) Expect(acme.Tenancy).To(MatchJSON(`{"org":"acme"}`)) // Tenant "globex" creates a cluster with the SAME name — allowed, different tenancy. - globex, svcErr := createInTx(ctxGlobex, sf, svc, newTenancyCluster(tenancyClusterName)) + globex, svcErr := svc.Create(ctxGlobex, tenancyClusterKind, newTenancyCluster(tenancyClusterName), nil) Expect(svcErr).To(BeNil(), "cross-tenant same-name create should succeed") Expect(globex.Tenancy).To(MatchJSON(`{"org":"globex"}`)) // Tenant "acme" creates "prod" again — conflict within its own tenancy. - _, svcErr = createInTx(ctxAcme, sf, svc, newTenancyCluster(tenancyClusterName)) + _, svcErr = svc.Create(ctxAcme, tenancyClusterKind, newTenancyCluster(tenancyClusterName), nil) Expect(svcErr).NotTo(BeNil(), "same-tenant duplicate name should be rejected") Expect(svcErr.IsConflict()).To(BeTrue()) Expect(svcErr.HTTPCode).To(Equal(http.StatusConflict)) @@ -99,23 +71,23 @@ func TestClusterNameUniquenessIsTenantScoped(t *testing.T) { func TestClusterRecreateAfterDeleteWithTenancy(t *testing.T) { h, _ := test.RegisterIntegration(t) svc := h.Container.ResourceService() - sf := h.Container.SessionFactory() ctxAcme := tenancyCtx(map[string]string{tenancyOrgKey: "acme"}) - created, svcErr := createInTx(ctxAcme, sf, svc, newTenancyCluster(tenancyClusterName)) + created, svcErr := svc.Create(ctxAcme, tenancyClusterKind, newTenancyCluster(tenancyClusterName), nil) Expect(svcErr).To(BeNil()) // A second create with the same name in the same tenancy conflicts. - _, svcErr = createInTx(ctxAcme, sf, svc, newTenancyCluster(tenancyClusterName)) + _, svcErr = svc.Create(ctxAcme, tenancyClusterKind, newTenancyCluster(tenancyClusterName), nil) Expect(svcErr).NotTo(BeNil(), "same-tenant duplicate name should be rejected") Expect(svcErr.IsConflict()).To(BeTrue()) Expect(svcErr.HTTPCode).To(Equal(http.StatusConflict)) // Delete the original, then recreate with the same name — the deleted row no longer // participates in the unique index. - Expect(deleteInTx(ctxAcme, sf, svc, created.ID)).To(BeNil()) + _, svcErr = svc.Delete(ctxAcme, tenancyClusterKind, created.ID) + Expect(svcErr).To(BeNil()) - _, svcErr = createInTx(ctxAcme, sf, svc, newTenancyCluster(tenancyClusterName)) + _, svcErr = svc.Create(ctxAcme, tenancyClusterKind, newTenancyCluster(tenancyClusterName), nil) Expect(svcErr).To(BeNil(), "recreate after delete should succeed") } diff --git a/test/integration/transaction_test.go b/test/integration/transaction_test.go index 32fbd709..d1426354 100644 --- a/test/integration/transaction_test.go +++ b/test/integration/transaction_test.go @@ -2,208 +2,125 @@ package integration import ( "context" + "errors" "testing" . "github.com/onsi/gomega" + "gorm.io/gorm" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" "github.com/openshift-hyperfleet/hyperfleet-api/test" ) -// TestTransactionRollbackWithDAO verifies that when TransactionMiddleware creates -// a transaction and a DAO operation calls MarkForRollback, the data is actually -// rolled back. This test validates whether GORM operations truly participate in -// the TransactionMiddleware's GORM transaction. -// -// CRITICAL TEST: This proves/disproves whether current architecture has working transactions -// -// Expected behavior (if transactions work): -// - Create transaction via db.NewContext() -// - DAO inserts a cluster -// - MarkForRollback is called -// - Resolve() rollback the transaction -// - Query database: cluster should NOT exist (rollback succeeded) -// -// Actual behavior (if transactions DON'T work): -// - Create transaction via db.NewContext() -// - DAO inserts a cluster (uses connection pool, not the transaction) -// - Data commits immediately (autocommit mode) -// - MarkForRollback sets a flag -// - Resolve() rollback the transaction (but GORM operations already committed) -// - Query database: cluster STILL EXISTS (rollback failed) -func TestTransactionRollbackWithDAO(t *testing.T) { - h, _ := test.RegisterIntegration(t) - - // Step 1: Create a transaction context (simulating TransactionMiddleware) - ctx := context.Background() - ctx, err := db.NewContext(ctx, h.DBFactory) - Expect(err).NotTo(HaveOccurred(), "Failed to create transaction context") - - // Step 2: Create a cluster using DAO - // This simulates what happens in a real POST request handler - cluster := &api.Resource{ - Meta: api.Meta{ - ID: h.NewID(), - }, - Kind: "Cluster", - Name: "transaction-test-cluster", - CreatedBy: "test-user", - UpdatedBy: "test-user", - Spec: []byte(`{"test": "rollback"}`), - } +type failingResourceLabelDao struct{ err error } - // Use sessionFactory.New(ctx) - this is what DAOs do - g2 := h.DBFactory.New(ctx) - err = g2.Create(cluster).Error - Expect(err).NotTo(HaveOccurred(), "Failed to create cluster via GORM") - - // Step 3: Verify cluster was inserted (in-transaction visibility) - var inTransactionCluster api.Resource - err = g2.Where("id = ?", cluster.ID).First(&inTransactionCluster).Error - Expect(err).NotTo(HaveOccurred(), "Should see cluster within transaction") - - // Step 4: Mark transaction for rollback (simulating DAO error handling) - db.MarkForRollback(ctx, err) - - // Step 5: Resolve the transaction (this should rollback) - db.Resolve(ctx) - - // Step 6: CRITICAL CHECK - Query database with NEW session (outside transaction) - // If transactions work: cluster should NOT exist - // If transactions DON'T work: cluster WILL exist - freshCtx := context.Background() - freshSession := h.DBFactory.New(freshCtx) - - var afterRollbackCluster api.Resource - err = freshSession.Where("id = ?", cluster.ID).First(&afterRollbackCluster).Error - - // THE MOMENT OF TRUTH - if err == nil { - // Cluster still exists after rollback - t.Errorf("🔴 CRITICAL FAILURE: Cluster exists after Resolve() rollback") - t.Errorf(" Cluster ID: %s", afterRollbackCluster.ID) - t.Errorf(" This proves GORM operations do NOT use TransactionMiddleware's transaction") - t.Errorf(" Architecture assumption is WRONG") - t.FailNow() - } else { - // Cluster does not exist - rollback worked - t.Logf("✅ SUCCESS: Cluster was rolled back") - t.Logf(" GORM operations DO use TransactionMiddleware's transaction") - t.Logf(" Architecture assumption is CORRECT") - } +func (d failingResourceLabelDao) ReplaceLabels( + context.Context, string, []api.ResourceLabel, +) error { + return d.err } -// TestTransactionCommitWithDAO verifies the positive case: when no error occurs, -// the transaction commits successfully and data persists. -func TestTransactionCommitWithDAO(t *testing.T) { - h, _ := test.RegisterIntegration(t) - - // Step 1: Create a transaction context - ctx := context.Background() - ctx, err := db.NewContext(ctx, h.DBFactory) - Expect(err).NotTo(HaveOccurred(), "Failed to create transaction context") +var _ dao.ResourceLabelDao = failingResourceLabelDao{} - // Step 2: Create a cluster using DAO - cluster := &api.Resource{ - Meta: api.Meta{ - ID: h.NewID(), - }, +func transactionTestCluster(h *test.Helper, name string) *api.Resource { + return &api.Resource{ + Meta: api.Meta{ID: h.NewID()}, Kind: "Cluster", - Name: "transaction-commit-test", + Name: name, CreatedBy: "test-user", UpdatedBy: "test-user", - Spec: []byte(`{"test": "commit"}`), + Spec: []byte(`{"test": "transaction"}`), } - - g2 := h.DBFactory.New(ctx) - err = g2.Create(cluster).Error - Expect(err).NotTo(HaveOccurred(), "Failed to create cluster via GORM") - - // Step 3: Do NOT call MarkForRollback - let it commit normally - // Step 4: Resolve the transaction (this should commit) - db.Resolve(ctx) - - // Step 5: Query database with fresh session - freshCtx := context.Background() - freshSession := h.DBFactory.New(freshCtx) - - var committedCluster api.Resource - err = freshSession.Where("id = ?", cluster.ID).First(&committedCluster).Error - - // Should find the cluster - Expect(err).NotTo(HaveOccurred(), "Cluster should exist after commit") - Expect(committedCluster.ID).To(Equal(cluster.ID)) - Expect(committedCluster.Name).To(Equal(cluster.Name)) - t.Logf("✅ Cluster successfully committed: %s", committedCluster.ID) } -// TestMultipleDAOOperationsInTransaction verifies atomicity across multiple -// DAO operations. If transactions work, both operations should rollback together. -func TestMultipleDAOOperationsInTransaction(t *testing.T) { +func TestTransactionRollbackWithDAO(t *testing.T) { h, _ := test.RegisterIntegration(t) + resourceDao := h.Container.ResourceDao() + cluster := transactionTestCluster(h, "transaction-rollback-"+h.NewID()) + wantErr := errors.New("force rollback") + + runner := db.NewTxRunner(h.DBFactory) + err := runner.Do(context.Background(), func(ctx context.Context) error { + _, err := resourceDao.Create(ctx, cluster) + if err != nil { + return err + } + return wantErr + }) + Expect(err).To(MatchError(wantErr)) - ctx := context.Background() - ctx, err := db.NewContext(ctx, h.DBFactory) - Expect(err).NotTo(HaveOccurred(), "Failed to create transaction context") - - // Create two clusters in the same transaction - cluster1 := &api.Resource{ - Meta: api.Meta{ID: h.NewID()}, - Kind: "Cluster", - Name: "multi-op-cluster-1", - CreatedBy: "test-user", - UpdatedBy: "test-user", - Spec: []byte(`{"op": 1}`), - } - - cluster2 := &api.Resource{ - Meta: api.Meta{ID: h.NewID()}, - Kind: "Cluster", - Name: "multi-op-cluster-2", - CreatedBy: "test-user", - UpdatedBy: "test-user", - Spec: []byte(`{"op": 2}`), - } - - g2 := h.DBFactory.New(ctx) - - // First operation succeeds - err = g2.Create(cluster1).Error - Expect(err).NotTo(HaveOccurred(), "Failed to create first cluster") - - // Second operation succeeds - err = g2.Create(cluster2).Error - Expect(err).NotTo(HaveOccurred(), "Failed to create second cluster") - - // Mark for rollback (simulating error in third operation) - db.MarkForRollback(ctx, err) - - // Resolve (should rollback both operations) - db.Resolve(ctx) - - // Check if BOTH clusters were rolled back - freshCtx := context.Background() - freshSession := h.DBFactory.New(freshCtx) + var persisted api.Resource + err = h.DBFactory.New(context.Background()).Where("id = ?", cluster.ID).First(&persisted).Error + Expect(errors.Is(err, gorm.ErrRecordNotFound)).To(BeTrue()) +} - var check1 api.Resource - err1 := freshSession.Where("id = ?", cluster1.ID).First(&check1).Error +func TestTransactionCommitWithDAO(t *testing.T) { + h, _ := test.RegisterIntegration(t) + resourceDao := h.Container.ResourceDao() + cluster := transactionTestCluster(h, "transaction-commit-"+h.NewID()) + + runner := db.NewTxRunner(h.DBFactory) + err := runner.Do(context.Background(), func(ctx context.Context) error { + _, err := resourceDao.Create(ctx, cluster) + return err + }) + Expect(err).NotTo(HaveOccurred()) + + var persisted api.Resource + err = h.DBFactory.New(context.Background()).Where("id = ?", cluster.ID).First(&persisted).Error + Expect(err).NotTo(HaveOccurred()) + Expect(persisted.ID).To(Equal(cluster.ID)) +} - var check2 api.Resource - err2 := freshSession.Where("id = ?", cluster2.ID).First(&check2).Error +func TestResourceServiceCreateRollsBackWhenLabelPersistenceFails(t *testing.T) { + h, _ := test.RegisterIntegration(t) + cluster := transactionTestCluster(h, "transaction-label-rollback-"+h.NewID()) + cluster.Labels = []api.ResourceLabel{{Key: "environment", Value: "test"}} + labelErr := errors.New("label persistence failed") + + service, err := services.NewResourceService( + h.Container.ResourceDao(), + failingResourceLabelDao{err: labelErr}, + h.Container.AdapterStatusDao(), + h.Container.ResourceConditionDao(), + h.Container.GenericService(), + h.Container.TxRunner(), + ) + Expect(err).NotTo(HaveOccurred()) + + created, svcErr := service.Create(context.Background(), "Cluster", cluster, nil) + Expect(created).To(BeNil()) + Expect(svcErr).NotTo(BeNil()) + Expect(svcErr.Reason).To(ContainSubstring(labelErr.Error())) + + var persisted api.Resource + err = h.DBFactory.New(context.Background()).Where("id = ?", cluster.ID).First(&persisted).Error + Expect(errors.Is(err, gorm.ErrRecordNotFound)).To(BeTrue()) +} - // Both should NOT exist if transactions work - if err1 == nil || err2 == nil { - t.Errorf("🔴 ATOMICITY FAILURE:") - if err1 == nil { - t.Errorf(" Cluster 1 still exists: %s", cluster1.ID) +func TestMultipleDAOOperationsRollbackTogether(t *testing.T) { + h, _ := test.RegisterIntegration(t) + resourceDao := h.Container.ResourceDao() + cluster1 := transactionTestCluster(h, "transaction-multi-1-"+h.NewID()) + cluster2 := transactionTestCluster(h, "transaction-multi-2-"+h.NewID()) + + runner := db.NewTxRunner(h.DBFactory) + err := runner.Do(context.Background(), func(ctx context.Context) error { + if _, err := resourceDao.Create(ctx, cluster1); err != nil { + return err } - if err2 == nil { - t.Errorf(" Cluster 2 still exists: %s", cluster2.ID) + if _, err := resourceDao.Create(ctx, cluster2); err != nil { + return err } - t.Errorf(" Multiple DAO operations are NOT atomic") - t.FailNow() - } else { - t.Logf("✅ ATOMICITY SUCCESS: Both clusters rolled back together") - } + return errors.New("rollback both writes") + }) + Expect(err).To(HaveOccurred()) + + fresh := h.DBFactory.New(context.Background()) + var persisted api.Resource + Expect(errors.Is(fresh.Where("id = ?", cluster1.ID).First(&persisted).Error, gorm.ErrRecordNotFound)).To(BeTrue()) + Expect(errors.Is(fresh.Where("id = ?", cluster2.ID).First(&persisted).Error, gorm.ErrRecordNotFound)).To(BeTrue()) } From 513c86ca97b046b41de9b6e42866e492dd18497a Mon Sep 17 00:00:00 2001 From: Dmitrii Andreev Date: Mon, 14 Sep 2026 19:29:55 -0500 Subject: [PATCH 09/41] HYPERFLEET-892 - feat: Align API handler with shared slog config package --- CONTRIBUTING.md | 2 +- cmd/hyperfleet-api/container/validation.go | 3 +- cmd/hyperfleet-api/main.go | 33 +- cmd/hyperfleet-api/migrate/cmd.go | 28 +- cmd/hyperfleet-api/servecmd/cmd.go | 71 +- cmd/hyperfleet-api/server/compress.go | 5 +- .../logging/request_logging_middleware.go | 14 +- cmd/hyperfleet-api/server/server.go | 7 +- docs/config.md | 9 +- docs/logging.md | 778 ++++++++-------- go.mod | 1 + go.sum | 2 + pkg/api/error.go | 9 +- pkg/api/response/problem_details.go | 8 +- pkg/auth/helpers.go | 5 +- pkg/auth/helpers_test.go | 49 + pkg/auth/jwt_handler.go | 11 +- pkg/closer/closer.go | 7 +- pkg/config/flags.go | 2 +- pkg/config/loader.go | 35 +- pkg/config/logging_test.go | 35 +- pkg/db/advisory_locks.go | 3 +- pkg/db/context.go | 17 +- pkg/db/db_session/default.go | 28 +- pkg/db/db_session/test.go | 5 +- pkg/db/migrations.go | 8 +- pkg/db/transactions.go | 8 +- pkg/errors/errors.go | 5 +- pkg/handlers/helpers.go | 27 +- pkg/handlers/metadata.go | 15 +- pkg/handlers/openapi.go | 19 +- pkg/handlers/resource_status_handler.go | 8 +- pkg/handlers/resource_status_handler_test.go | 77 ++ pkg/handlers/root_resource_handler.go | 8 +- pkg/health/handler.go | 4 +- pkg/logger/context.go | 110 +-- pkg/logger/fields.go | 35 +- pkg/logger/gorm_logger.go | 21 +- pkg/logger/handler.go | 48 + pkg/logger/handler_test.go | 91 ++ pkg/logger/http.go | 20 +- pkg/logger/logger.go | 320 ------- pkg/logger/logger_test.go | 841 ------------------ pkg/logger/requestid_middleware.go | 3 +- pkg/logger/text_handler.go | 134 --- pkg/logger/text_handler_test.go | 264 ------ pkg/metrics/reconciliation.go | 8 +- pkg/middleware/masking.go | 12 +- pkg/middleware/masking_test.go | 3 +- pkg/middleware/otel.go | 6 +- pkg/middleware/otel_test.go | 50 +- pkg/middleware/schema_validation.go | 15 +- pkg/services/adapter_status_validation.go | 20 +- .../adapter_status_validation_test.go | 25 +- pkg/services/aggregation.go | 9 +- pkg/services/condition_mapper.go | 48 +- pkg/services/resource.go | 49 +- pkg/services/resource_logging_test.go | 121 +++ pkg/services/util.go | 26 - pkg/services/util_test.go | 86 -- pkg/telemetry/otel.go | 27 +- pkg/tenant/middleware.go | 4 +- pkg/validators/schema_validator.go | 8 +- test/helper.go | 31 +- test/helper_test.go | 18 + test/integration/integration_test.go | 22 +- 66 files changed, 1198 insertions(+), 2623 deletions(-) create mode 100644 pkg/auth/helpers_test.go create mode 100644 pkg/logger/handler.go create mode 100644 pkg/logger/handler_test.go delete mode 100755 pkg/logger/logger.go delete mode 100644 pkg/logger/logger_test.go delete mode 100644 pkg/logger/text_handler.go delete mode 100644 pkg/logger/text_handler_test.go create mode 100644 pkg/services/resource_logging_test.go create mode 100644 test/helper_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aac1f927..f96d7c63 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,7 +66,7 @@ hyperfleet-api/ │ ├── db/ # Database session factory, migrations, transaction middleware │ ├── errors/ # RFC 9457 Problem Details error model │ ├── handlers/ # HTTP handler pattern, validation and error handling -│ ├── logger/ # Structured logging (slog-based) +│ ├── logger/ # API logging integration (request ID, HTTP attrs, GORM adapter) │ ├── presenters/ # Response presenters (DAO models → API responses) │ └── services/ # Business logic layer (status aggregation, validation) ├── openapi/ # API specification source diff --git a/cmd/hyperfleet-api/container/validation.go b/cmd/hyperfleet-api/container/validation.go index fb9b0dbb..26b6ca84 100644 --- a/cmd/hyperfleet-api/container/validation.go +++ b/cmd/hyperfleet-api/container/validation.go @@ -3,6 +3,7 @@ package container import ( "context" "fmt" + "log/slog" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/validators" @@ -16,7 +17,7 @@ func (c *Container) SchemaValidator() *validators.SchemaValidator { panic(fmt.Sprintf("create schema validator: %v", err)) } c.schemaValidator = schemaValidator - logger.With(context.Background(), logger.FieldSchemaPath, schemaPath).Info("Schema validation enabled") + slog.InfoContext(context.Background(), "Schema validation enabled", logger.FieldSchemaPath, schemaPath) } return c.schemaValidator } diff --git a/cmd/hyperfleet-api/main.go b/cmd/hyperfleet-api/main.go index 88d73edf..94d45312 100755 --- a/cmd/hyperfleet-api/main.go +++ b/cmd/hyperfleet-api/main.go @@ -7,6 +7,7 @@ import ( "log/slog" "os" + hfl "github.com/openshift-hyperfleet/hyperfleet-logger" "github.com/spf13/cobra" "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/migrate" @@ -36,27 +37,27 @@ func main() { rootCmd.AddCommand(migrateCmd, serveCmd, versionCmd) if err := rootCmd.Execute(); err != nil { - logger.WithError(ctx, err).Error("Error running command") + slog.ErrorContext(ctx, "Error running command", "error", err) os.Exit(1) } } // initDefaultLogger initializes a default logger with INFO level // This ensures logging works before environment/config is loaded -// Reads HYPERFLEET_LOGGING_* from environment variables if set +// Reads HYPERFLEET_LOGGING_* variables if set. func initDefaultLogger() { // Read log level from environment with default fallback level := slog.LevelInfo if levelStr := os.Getenv("HYPERFLEET_LOGGING_LEVEL"); levelStr != "" { - if parsed, err := logger.ParseLogLevel(levelStr); err == nil { + if parsed, err := hfl.ParseLevel(levelStr); err == nil { level = parsed } } // Read log format from environment with default fallback - format := logger.FormatJSON + format := hfl.FormatJSON if formatStr := os.Getenv("HYPERFLEET_LOGGING_FORMAT"); formatStr != "" { - if parsed, err := logger.ParseLogFormat(formatStr); err == nil { + if parsed, err := hfl.ParseFormat(formatStr); err == nil { format = parsed } } @@ -64,28 +65,14 @@ func initDefaultLogger() { // Read log output from environment with default fallback var output io.Writer = os.Stdout if outputStr := os.Getenv("HYPERFLEET_LOGGING_OUTPUT"); outputStr != "" { - if parsed, err := logger.ParseLogOutput(outputStr); err == nil { + if parsed, err := hfl.ParseOutput(outputStr); err == nil { output = parsed } } - cfg := &logger.LogConfig{ - Level: level, - Format: format, - Output: output, - Component: "hyperfleet-api", - Version: api.Version, - Hostname: getHostname(), - } - logger.InitGlobalLogger(cfg) -} - -func getHostname() string { - hostname, err := os.Hostname() - if err != nil { - return "unknown" - } - return hostname + slog.SetDefault(logger.NewLogger(api.Version, logger.HandlerConfig{ + Level: level, Format: format, Output: output, + })) } func newVersionCommand() *cobra.Command { diff --git a/cmd/hyperfleet-api/migrate/cmd.go b/cmd/hyperfleet-api/migrate/cmd.go index 56bfee09..bb7ec064 100755 --- a/cmd/hyperfleet-api/migrate/cmd.go +++ b/cmd/hyperfleet-api/migrate/cmd.go @@ -2,10 +2,13 @@ package migrate import ( "context" + "log/slog" "os" + hfl "github.com/openshift-hyperfleet/hyperfleet-logger" "github.com/spf13/cobra" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/db_session" @@ -40,9 +43,10 @@ func runMigrate(cmd *cobra.Command, _ []string) { loader := config.NewConfigLoader() appConfig, err := loader.Load(ctx, cmd) if err != nil { - logger.WithError(ctx, err).Error("Failed to load configuration") + slog.ErrorContext(ctx, "Failed to load configuration", "error", err) os.Exit(1) } + initLogger(appConfig.Logging) // Run migration with the loaded configuration if err := runMigrateWithError(ctx, appConfig.Database); err != nil { @@ -50,17 +54,35 @@ func runMigrate(cmd *cobra.Command, _ []string) { } } +func initLogger(loggingCfg *config.LoggingConfig) { + level, err := hfl.ParseLevel(loggingCfg.Level) + if err != nil { + level = slog.LevelInfo + } + format, err := hfl.ParseFormat(loggingCfg.Format) + if err != nil { + format = hfl.FormatJSON + } + output, err := hfl.ParseOutput(loggingCfg.Output) + if err != nil { + output = os.Stdout + } + slog.SetDefault(logger.NewLogger(api.Version, logger.HandlerConfig{ + Level: level, Format: format, Output: output, + })) +} + func runMigrateWithError(ctx context.Context, dbConfig *config.DatabaseConfig) error { connection := db_session.NewProdFactory(dbConfig) defer func() { if closeErr := connection.Close(); closeErr != nil { - logger.WithError(ctx, closeErr).Error("Failed to close database connection") + slog.ErrorContext(ctx, "Failed to close database connection", "error", closeErr) } }() // Use MigrateWithLock to prevent concurrent migrations from multiple pods if err := db.MigrateWithLock(ctx, connection); err != nil { - logger.WithError(ctx, err).Error("Migration failed") + slog.ErrorContext(ctx, "Migration failed", "error", err) return err } diff --git a/cmd/hyperfleet-api/servecmd/cmd.go b/cmd/hyperfleet-api/servecmd/cmd.go index 34801177..91956519 100755 --- a/cmd/hyperfleet-api/servecmd/cmd.go +++ b/cmd/hyperfleet-api/servecmd/cmd.go @@ -11,6 +11,7 @@ import ( "syscall" "time" + hfl "github.com/openshift-hyperfleet/hyperfleet-logger" "github.com/spf13/cobra" "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/container" @@ -57,8 +58,9 @@ func runServe(cmd *cobra.Command, args []string) (runErr error) { // container.Container's accessors panic by design; log the stack here since main.go can't see it. defer func() { if r := recover(); r != nil { - logger.With(context.Background(), "panic_stack", string(debug.Stack())). - Error(fmt.Sprintf("recovered from panic in runServe: %v", r)) + slog.ErrorContext(context.Background(), + fmt.Sprintf("recovered from panic in runServe: %v", r), "panic_stack", string(debug.Stack()), + ) runErr = fmt.Errorf("%v", r) } }() @@ -82,27 +84,27 @@ func runServe(cmd *cobra.Command, args []string) (runErr error) { closeErr := c.Close() runErr = errors.Join(runErr, closeErr) if runErr == nil { - logger.Info(context.Background(), "Graceful shutdown completed") + slog.InfoContext(context.Background(), "Graceful shutdown completed") } }() - ctr := container.NewContainer(cfg, c) - initLogger(cfg) + ctr := container.NewContainer(cfg, c) + sf := ctr.SessionFactory() configureDBLogger(cfg, sf) - logger.Info(ctx, "Starting HyperFleet API with configuration (sensitive values redacted):") - logger.Info(ctx, config.DumpConfig(cfg)) + slog.InfoContext(ctx, "Starting HyperFleet API with configuration (sensitive values redacted):") + slog.InfoContext(ctx, config.DumpConfig(cfg)) // OTel registered first so it flushes last - teardown spans are preserved. if cfg.Tracing.Enabled { traceProvider, traceErr := telemetry.InitTraceProvider(ctx, cfg.Tracing.ServiceName, api.Version) if traceErr != nil { - logger.WithError(ctx, traceErr).Warn("Failed to initialize OpenTelemetry") + slog.WarnContext(ctx, "Failed to initialize OpenTelemetry", "error", traceErr) } else { - logger.With(ctx, logger.FieldServiceName, cfg.Tracing.ServiceName).Info("OpenTelemetry initialized") + slog.InfoContext(ctx, "OpenTelemetry initialized", logger.FieldServiceName, cfg.Tracing.ServiceName) c.Add(func() error { flushCtx, cancel := context.WithTimeout(context.Background(), otelFlushTimeout) defer cancel() @@ -110,21 +112,20 @@ func runServe(cmd *cobra.Command, args []string) (runErr error) { }) } } else { - logger.With(ctx, logger.FieldOTelEnabled, false).Info("OpenTelemetry disabled") + slog.InfoContext(ctx, "OpenTelemetry disabled", logger.FieldOTelEnabled, false) } - - logger.With(ctx, - "log_level", cfg.Logging.Level, + slog.InfoContext(ctx, + "Logger initialized", "log_level", cfg.Logging.Level, "log_format", cfg.Logging.Format, "log_output", cfg.Logging.Output, "masking_enabled", cfg.Logging.Masking.Enabled, - ).Info("Logger initialized") + ) if collectorErr := metrics.RegisterReconciliationCollector( ctr.SessionFactory().DirectDB(), cfg.Metrics.ReconciliationStuckThreshold, ); collectorErr != nil { - logger.WithError(ctx, collectorErr).Error("Failed to register reconciliation collector") + slog.ErrorContext(ctx, "Failed to register reconciliation collector", "error", collectorErr) } apiServer, err := BuildAPIServer( @@ -150,7 +151,7 @@ func runServe(cmd *cobra.Command, args []string) (runErr error) { // Readyz registered last so it runs first - immediately fails the probe. c.Add(func() error { health.GetReadinessState().SetShuttingDown() - logger.Info(context.Background(), "Marked as not ready, draining in-flight requests...") + slog.InfoContext(context.Background(), "Marked as not ready, draining in-flight requests...") return nil }) @@ -190,7 +191,7 @@ func runServe(cmd *cobra.Command, args []string) (runErr error) { } if triggerErr == nil && !shutdown { health.GetReadinessState().SetReady() - logger.Info(ctx, "Application ready to receive traffic") + slog.InfoContext(ctx, "Application ready to receive traffic") select { case <-ctx.Done(): case <-signals: @@ -198,7 +199,7 @@ func runServe(cmd *cobra.Command, args []string) (runErr error) { } } - logger.Info(context.Background(), "Shutdown requested, starting graceful shutdown...") + slog.InfoContext(context.Background(), "Shutdown requested, starting graceful shutdown...") runErr = triggerErr return runErr } @@ -218,41 +219,27 @@ func initLogger(cfg *config.ApplicationConfig) { ctx := context.Background() loggingCfg := cfg.Logging - level, err := logger.ParseLogLevel(loggingCfg.Level) + level, err := hfl.ParseLevel(loggingCfg.Level) if err != nil { - logger.With(ctx, logger.FieldLogLevel, loggingCfg.Level).WithError(err).Warn("Invalid log level, using default") + slog.WarnContext(ctx, "Invalid log level, using default", logger.FieldLogLevel, loggingCfg.Level, "error", err) level = slog.LevelInfo } - format, err := logger.ParseLogFormat(loggingCfg.Format) + format, err := hfl.ParseFormat(loggingCfg.Format) if err != nil { - logger.With(ctx, logger.FieldLogFormat, loggingCfg.Format).WithError(err).Warn("Invalid log format, using default") - format = logger.FormatJSON + slog.WarnContext(ctx, "Invalid log format, using default", logger.FieldLogFormat, loggingCfg.Format, "error", err) + format = hfl.FormatJSON } - output, err := logger.ParseLogOutput(loggingCfg.Output) + output, err := hfl.ParseOutput(loggingCfg.Output) if err != nil { - logger.With(ctx, logger.FieldLogOutput, loggingCfg.Output).WithError(err).Warn("Invalid log output, using default") + slog.WarnContext(ctx, "Invalid log output, using default", logger.FieldLogOutput, loggingCfg.Output, "error", err) output = os.Stdout } - hostname := cfg.Server.Hostname - if hostname == "" { - hostname, _ = os.Hostname() //nolint:errcheck // empty string is acceptable fallback - } - - logConfig := &logger.LogConfig{ - Level: level, - Format: format, - Output: output, - Component: "api", - Version: api.Version, - Hostname: hostname, - } - - // Use ReconfigureGlobalLogger instead of InitGlobalLogger because - // InitGlobalLogger was already called in main() with default config - logger.ReconfigureGlobalLogger(logConfig) + slog.SetDefault(logger.NewLogger(api.Version, logger.HandlerConfig{ + Level: level, Format: format, Output: output, + })) } func configureDBLogger(cfg *config.ApplicationConfig, sessionFactory db.SessionFactory) { diff --git a/cmd/hyperfleet-api/server/compress.go b/cmd/hyperfleet-api/server/compress.go index de8147e8..cae760b7 100644 --- a/cmd/hyperfleet-api/server/compress.go +++ b/cmd/hyperfleet-api/server/compress.go @@ -2,11 +2,10 @@ package server import ( "compress/gzip" + "log/slog" "net/http" "strconv" "strings" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) // gzipResponseWriter wraps an http.ResponseWriter, transparently gzip-encoding @@ -73,7 +72,7 @@ func CompressMiddleware(next http.Handler) http.Handler { gz := gzip.NewWriter(w) defer func() { if err := gz.Close(); err != nil { - logger.WithError(r.Context(), err).Warn("failed to finalize gzip response, response may be incomplete") + slog.WarnContext(r.Context(), "failed to finalize gzip response, response may be incomplete", "error", err) } }() diff --git a/cmd/hyperfleet-api/server/logging/request_logging_middleware.go b/cmd/hyperfleet-api/server/logging/request_logging_middleware.go index 2f0bddf3..59ad62b8 100755 --- a/cmd/hyperfleet-api/server/logging/request_logging_middleware.go +++ b/cmd/hyperfleet-api/server/logging/request_logging_middleware.go @@ -30,14 +30,13 @@ func RequestLoggingMiddleware(masker *middleware.MaskingMiddleware) func(http.Ha } else { maskedHeaders = r.Header } - - logger.With(ctx, - logger.HTTPMethod(r.Method), + slog.InfoContext(ctx, + "HTTP request received", logger.HTTPMethod(r.Method), logger.HTTPPath(r.URL.Path), slog.String("remote_addr", r.RemoteAddr), logger.HTTPUserAgent(r.UserAgent()), slog.Any("headers", maskedHeaders), - ).Info("HTTP request received") + ) rw := &responseWriter{ResponseWriter: w} @@ -48,15 +47,14 @@ func RequestLoggingMiddleware(masker *middleware.MaskingMiddleware) func(http.Ha if rw.statusCode == 0 { rw.statusCode = http.StatusOK } - - logger.With(ctx, - logger.HTTPMethod(r.Method), + slog.InfoContext(ctx, + "HTTP request completed", logger.HTTPMethod(r.Method), logger.HTTPPath(r.URL.Path), logger.HTTPStatusCode(rw.statusCode), logger.HTTPDuration(duration), slog.String("remote_addr", r.RemoteAddr), logger.HTTPUserAgent(r.UserAgent()), - ).Info("HTTP request completed") + ) }) } } diff --git a/cmd/hyperfleet-api/server/server.go b/cmd/hyperfleet-api/server/server.go index 27a22dcb..60d73ec2 100755 --- a/cmd/hyperfleet-api/server/server.go +++ b/cmd/hyperfleet-api/server/server.go @@ -5,6 +5,7 @@ import ( "crypto/tls" "errors" "fmt" + "log/slog" "net" "net/http" "strings" @@ -48,16 +49,16 @@ func (s *baseServer) Serve(listener net.Listener) error { return errors.Join(configErr, listener.Close()) } - logger.With(ctx, logger.FieldBindAddress, s.httpServer.Addr).Info("Serving " + s.name + " with TLS") + slog.InfoContext(ctx, "Serving "+s.name+" with TLS", logger.FieldBindAddress, s.httpServer.Addr) err = s.httpServer.ServeTLS(listener, s.cfg.TLSCertFile(), s.cfg.TLSKeyFile()) } else { - logger.With(ctx, logger.FieldBindAddress, s.httpServer.Addr).Info("Serving " + s.name + " without TLS") + slog.InfoContext(ctx, "Serving "+s.name+" without TLS", logger.FieldBindAddress, s.httpServer.Addr) err = s.httpServer.Serve(listener) } if err != nil && !errors.Is(err, http.ErrServerClosed) { return fmt.Errorf("%s terminated with errors: %w", s.name, err) } - logger.Info(ctx, s.name+" terminated") + slog.InfoContext(ctx, s.name+" terminated") return nil } diff --git a/docs/config.md b/docs/config.md index 5978d59b..1a8cc674 100644 --- a/docs/config.md +++ b/docs/config.md @@ -175,8 +175,8 @@ Logging behavior and output settings. | `logging.level` | string | `info` | Log level: `debug`, `info`, `warn`, `error` | | `logging.format` | string | `json` | Log format: `json`, `text` | | `logging.output` | string | `stdout` | Log output: `stdout`, `stderr` | -| `logging.otel.enabled` | bool | `true` | Enable OpenTelemetry tracing (see [OpenTelemetry Configuration](#opentelemetry-configuration)) | -| `logging.masking.enabled` | bool | `true` | Enable sensitive data masking in logs | +| `logging.otel.enabled` (deprecated) | bool | `true` | Legacy OpenTelemetry setting; `tracing.enabled` is active and takes precedence when both are configured. If `tracing.enabled` is unset, this setting is used as a fallback. | +| `logging.masking.enabled` | bool | `true` | Enable sensitive request-header masking in logs | **Example:** @@ -203,7 +203,7 @@ OpenTelemetry tracing is configured via standard environment variables following | Property | Environment Variable | Type | Default | Description | |----------|---------------------|------|---------|-------------| -| `logging.otel.enabled` | `HYPERFLEET_TRACING_ENABLED` | bool | `true` | Enable OpenTelemetry tracing (HyperFleet standard) | +| `tracing.enabled` | `HYPERFLEET_TRACING_ENABLED` | bool | `true` | Enable OpenTelemetry tracing (active setting; takes precedence over deprecated `logging.otel.enabled`) | **Standard OpenTelemetry Environment Variables:** @@ -477,7 +477,8 @@ Complete table of all configuration properties, their environment variables, and | `logging.level` | `HYPERFLEET_LOGGING_LEVEL` | string | `info` | | `logging.format` | `HYPERFLEET_LOGGING_FORMAT` | string | `json` | | `logging.output` | `HYPERFLEET_LOGGING_OUTPUT` | string | `stdout` | -| `logging.otel.enabled` | `HYPERFLEET_TRACING_ENABLED` | bool | `true` | +| `logging.otel.enabled` (deprecated) | `HYPERFLEET_TRACING_ENABLED` | bool | `true` | +| `tracing.enabled` | `HYPERFLEET_TRACING_ENABLED` | bool | `true` | | `logging.masking.enabled` | `HYPERFLEET_LOGGING_MASKING_ENABLED` | bool | `true` | | `logging.masking.headers` | `HYPERFLEET_LOGGING_MASKING_HEADERS` | csv | `Authorization,Cookie` | | `logging.masking.fields` | `HYPERFLEET_LOGGING_MASKING_FIELDS` | csv | `password,token` | diff --git a/docs/logging.md b/docs/logging.md index 5618dcae..f6ae56bc 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -1,537 +1,497 @@ # HyperFleet API Logging -This document describes the logging system used in hyperfleet-api. - -## Overview - -HyperFleet API uses Go's standard library `log/slog` for structured logging with the following features: - -- **Structured logging**: All logs use key-value pairs for better queryability -- **Context-aware logging**: Automatic request_id, trace_id, and span_id propagation -- **Data masking**: Sensitive data redaction in headers and JSON payloads -- **OpenTelemetry integration**: Distributed tracing with configurable sampling -- **JSON and text output**: Machine-parseable JSON or human-readable text format -- **Custom handler**: Automatic component, version, and hostname fields +HyperFleet API uses Go's `log/slog` for structured, context-aware logging. The +shared [`hyperfleet-logger`](https://github.com/openshift-hyperfleet/hyperfleet-logger) +module owns JSON and text formatting, standard fields, context extraction, +control-character sanitization, and stack capture. This repository owns the API +integration around that handler: request IDs, HTTP attributes, request-header +masking, the GORM adapter, and the API's stack-trace policy. + +## Features + +- Structured JSON output for log aggregation and human-readable text output for + local development +- A consistent `component=api`, application version, and OS/pod hostname on + every record +- Request, trace, span, and resource correlation through `context.Context` +- UUIDv7 request IDs returned in the `X-Request-ID` response header +- Sensitive request-header redaction with a configurable header list +- OpenTelemetry trace propagation and configurable export and sampling +- GORM query, slow-query, and error records using the same logger +- Automatic stack traces on JSON error records, matching the previous JSON logger ## Architecture -### Components +The relevant components are: -1. **pkg/logger/logger.go**: Core logger with HyperFleetHandler (custom slog.Handler) -2. **pkg/logger/context.go**: Context key definitions for trace_id, span_id, cluster_id, etc. -3. **pkg/logger/requestid_middleware.go**: Request ID generation and middleware -4. **pkg/middleware/otel.go**: OpenTelemetry trace context extraction -5. **pkg/telemetry/otel.go**: OpenTelemetry trace provider initialization +| Component | Responsibility | +| --- | --- | +| `pkg/logger/handler.go` | Builds the API logger around `hyperfleet-logger`; defines component identity, sanitization, and stack policy | +| `pkg/logger/context.go` | Defines the API-owned `request_id` context field | +| `pkg/logger/requestid_middleware.go` | Generates request IDs and sets `X-Request-ID` | +| `pkg/logger/http.go` | Provides typed `slog.Attr` helpers for HTTP fields | +| `pkg/logger/gorm_logger.go` | Adapts GORM logging to `slog` | +| `pkg/middleware/otel.go` | Creates/continues HTTP spans and adds trace/span IDs to log context | +| `pkg/middleware/masking.go` | Redacts configured request headers | +| `cmd/hyperfleet-api/server/logging/request_logging_middleware.go` | Emits request-start and request-completion records | +| `pkg/telemetry/otel.go` | Configures the OpenTelemetry provider, exporter, sampler, and propagators | -### Middleware Chain +The main API middleware is applied in this order: ```text -HTTP Request - ↓ -RequestIDMiddleware (adds request_id to context) - ↓ -OTelMiddleware (extracts trace_id and span_id, optional) - ↓ -RequestLoggingMiddleware (logs request/response with masking) - ↓ -Handler (business logic) +HTTP request + -> RequestIDMiddleware + -> OTelMiddleware (when tracing is enabled) + -> RequestLoggingMiddleware + -> API/auth/validation/transaction middleware + -> Handler -> Service -> DAO ``` -## Configuration +This order makes the request and trace identifiers available to request logs and +all downstream `slog.*Context` calls. -Logging is configured through environment variables or configuration files. +## Configuration -**Development:** -```bash -# Human-readable text format with debug level -export HYPERFLEET_LOGGING_FORMAT=text -export HYPERFLEET_LOGGING_LEVEL=debug -``` +Application configuration uses this precedence, from highest to lowest: -**Production:** -```bash -# Structured JSON format with info level -export HYPERFLEET_LOGGING_FORMAT=json -export HYPERFLEET_LOGGING_LEVEL=info +1. CLI flag +2. Environment variable +3. Configuration file +4. Default -# OpenTelemetry tracing (Tracing standard) -export HYPERFLEET_TRACING_ENABLED=true -export OTEL_TRACES_SAMPLER=parentbased_traceidratio -export OTEL_TRACES_SAMPLER_ARG=0.1 -export OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 -``` +### Logging settings -**For complete configuration reference**, including all logging settings (levels, formats, OpenTelemetry, masking), see: -- **[Configuration Guide](config.md)** - All logging environment variables and defaults +| Setting | CLI flag | Environment variable | Default | +| --- | --- | --- | --- | +| Level | `--log-level`, `-l` | `HYPERFLEET_LOGGING_LEVEL` | `info` | +| Format | `--log-format`, `-f` | `HYPERFLEET_LOGGING_FORMAT` | `json` | +| Output | `--log-output` | `HYPERFLEET_LOGGING_OUTPUT` | `stdout` | +| Masking enabled | `--log-masking-enabled` | `HYPERFLEET_LOGGING_MASKING_ENABLED` | `true` | +| Sensitive headers | `--log-masking-sensitive-headers` | `HYPERFLEET_LOGGING_MASKING_HEADERS` | See [Data masking](#data-masking) | -### OpenTelemetry Environment Variables +Valid levels are `debug`, `info`, `warn`, and `error`. Valid formats are `json` +and `text`; valid outputs are `stdout` and `stderr`. -HyperFleet uses standard OpenTelemetry environment variables for tracing configuration: +For local development: -| Variable | Description | Default | Example | -|----------|-------------|---------|---------| -| `HYPERFLEET_TRACING_ENABLED` | Enable/disable tracing (Tracing standard, overrides config) | `true` (config fallback) | `true`, `false` | -| `OTEL_SERVICE_NAME` | Service name in traces | `hyperfleet-api` | `hyperfleet-api-prod` | -| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector endpoint (if not set, uses stdout) | - | `http://otel-collector:4317` | -| `OTEL_EXPORTER_OTLP_PROTOCOL` | OTLP protocol | `grpc` | `grpc`, `http/protobuf` | -| `OTEL_TRACES_SAMPLER` | Sampler type | `parentbased_always_on` | `always_on`, `traceidratio` | -| `OTEL_TRACES_SAMPLER_ARG` | Argument passed to the sampler | - | `0.1` (10%) | -| `OTEL_RESOURCE_ATTRIBUTES` | Additional resource attributes | - | `env=prod,region=us-east` | +```bash +HYPERFLEET_LOGGING_LEVEL=debug \ +HYPERFLEET_LOGGING_FORMAT=text \ +./bin/hyperfleet-api serve +``` -**Variable Precedence (highest to lowest):** -1. `HYPERFLEET_TRACING_ENABLED` - Tracing standard (env var) -2. `config.yaml: logging.otel.enabled` - Config file -3. Default (`true`) +Equivalent YAML configuration is: + +```yaml +logging: + level: info + format: json + output: stdout + masking: + enabled: true + headers: + - Authorization + - X-API-Key + - Cookie +``` -## Usage +Both `serve` and `migrate` install the loaded logging level, format, and output. +Before the configuration file is loaded, bootstrap records use the three +`HYPERFLEET_LOGGING_{LEVEL,FORMAT,OUTPUT}` variables, falling back to the normal +defaults. See the [configuration guide](config.md) for the complete application +configuration reference. -### Basic Logging +## Writing application logs -Always use context-aware logging to include automatic fields (request_id, trace_id, span_id): +Use the standard `slog` context methods so correlation fields flow into every +record: ```go import ( - "context" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" -) - -func MyHandler(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() + "context" + "log/slog" - // Simple log (context fields only) - logger.Info(ctx, "Processing cluster creation") + hfl "github.com/openshift-hyperfleet/hyperfleet-logger" - // Log with temporary fields using With() - logger.With(ctx, "cluster_id", clusterID, "region", region).Info("Cluster created") - - // Error level (automatically includes stack trace) - logger.With(ctx, "host", "localhost").WithError(err).Error("Database connection failed") + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" +) - // Debug level - logger.With(ctx, "key", "cluster:123").Debug("Cache hit") +func updateResource(ctx context.Context, kind, id, adapter string) { + ctx = hfl.WithResourceType(ctx, kind) + ctx = hfl.WithResourceID(ctx, id) + ctx = logger.WithAdapter(ctx, adapter) - // Warning level - logger.With(ctx, "used_mb", 1024, "threshold_mb", 800).Warn("High memory usage") + slog.DebugContext(ctx, "Preparing resource update") + slog.InfoContext(ctx, "Resource updated") } ``` -Logs automatically include: -- `component`: "hyperfleet-api" -- `version`: Application version -- `hostname`: Pod/host name -- `request_id`: Unique request identifier -- `trace_id`: W3C trace ID (when OTel enabled) -- `span_id`: Current span ID (when OTel enabled) - -### Available Functions +Derive a child context from the one received by the handler or service. Do not +replace it with `context.Background()`, because that discards request and trace +correlation. For recursive or batched work, derive a separate resource context +for each item so identifiers do not leak into sibling operations. + +Use levels consistently: + +- `DEBUG`: diagnostic detail normally disabled in production +- `INFO`: normal lifecycle events and handled client responses +- `WARN`: degraded behavior, retryable problems, slow queries, and fallbacks +- `ERROR`: server-side failures requiring attention + +### Automatic fields + +| Field | Source | When present | +| --- | --- | --- | +| `timestamp` | Shared handler | Every record | +| `level` | `slog.Record` | Every record | +| `message` | `slog.Record` | Every record | +| `component` | API handler factory | Every record; always `api` | +| `version` | Build metadata | Every record | +| `hostname` | `os.Hostname()` through the shared handler | Every record; `unknown` if discovery fails | +| `request_id` | API request-ID middleware | HTTP request and downstream records | +| `trace_id`, `span_id` | OpenTelemetry middleware | Traced HTTP request and downstream records | +| `resource_type`, `resource_id` | Shared context helpers | Operations whose service context has been enriched with a known resource | +| `adapter` | `logger.WithAdapter` | Adapter report processing, including validation, persistence, and aggregation | + +`server.hostname` is the public server name and does not override the logging +hostname. + +The HTTP status handlers derive a child context with the incoming adapter name +after decoding and validating the report, and pass it through conversion, +service calls, presentation, and error handling. +Concurrent reports retain their own `adapter` and `request_id` fields without +changing the caller's context. When aggregation inspects another adapter's +report, its diagnostic log derives a context with that adapter's name. + +`RequestIDMiddleware` generates a UUIDv7 when its incoming context does not +already contain the API request-ID key. It preserves a request ID already placed +in that context and returns the selected value in `X-Request-ID`. It does not +trust an arbitrary inbound `X-Request-ID` header as the request ID. + +### Temporary fields and helpers + +Use structured attributes for values scoped to one record. Prefer constants +from `pkg/logger/fields.go` and HTTP helpers from `pkg/logger/http.go` when a +field is already defined: ```go -// Simple logging (context fields only) -logger.Info(ctx, "message") -logger.Warn(ctx, "message") -logger.Error(ctx, "message") -logger.Debug(ctx, "message") - -// Logging with temporary fields (use With()) -logger.With(ctx, "key", "value").Info("message") -logger.With(ctx, "key1", value1, "key2", value2).Error("message") - -// Logging with errors (use WithError()) -logger.WithError(ctx, err).Error("Operation failed") -logger.With(ctx, "host", "localhost").WithError(err).Error("Connection failed") - -// Chaining multiple With() calls -logger.With(ctx, "user_id", userID). - With("action", "login"). - WithError(err). - Error("Login failed") - -// Add persistent context fields -ctx = logger.WithClusterID(ctx, "cluster-123") -ctx = logger.WithResourceType(ctx, "managed-cluster") -ctx = logger.WithResourceID(ctx, "resource-456") -``` +slog.InfoContext(ctx, + "HTTP operation completed", + logger.HTTPMethod(r.Method), + logger.HTTPPath(r.URL.Path), + logger.HTTPStatusCode(http.StatusOK), + logger.HTTPDuration(elapsed), +) -### Field Constants +slog.WarnContext(ctx, + "Adapter report was ignored", + logger.FieldAdapter, adapter, + logger.FieldErrorCode, code, +) +``` -Use field constants from `pkg/logger/fields.go` to prevent typos and enable IDE autocomplete: +Do not log passwords, credentials, bearer tokens, raw connection strings, or +complete objects that may contain secrets. Masking the HTTP request log is not a +substitute for selecting safe fields at application call sites. -```go -logger.With(ctx, logger.FieldEnvironment, "production").Info("Environment loaded") -logger.With(ctx, logger.FieldBindAddress, ":8080").Info("Server starting") -logger.With(ctx, logger.FieldConnectionString, sanitized).Info("Database connected") -``` +## Output formats -See `pkg/logger/fields.go` for all available constants (Server/Config, Database, OpenTelemetry, etc.). +### JSON -### HTTP Helper Functions +JSON is the default and is intended for log aggregation. The handler uses +lowercase level names and does not add a `source` field. -Use HTTP helpers from `pkg/logger/http.go` for consistent HTTP field logging: +For this call: ```go -logger.With(r.Context(), - logger.HTTPMethod(r.Method), - logger.HTTPPath(r.URL.Path), - logger.HTTPStatusCode(200), -).Info("Request processed") +slog.InfoContext(ctx, "Resource updated", logger.FieldAdapter, "example") ``` -See `pkg/logger/http.go` for all available helpers (HTTPMethod, HTTPPath, HTTPStatusCode, etc.). - -## Format Comparison - -HyperFleet API supports two log output formats: **JSON** (for production/log aggregation) and **Text** (for development/debugging). - -### JSON Format (`LOG_FORMAT=json`) +a representative record is: -**Characteristics:** -- Machine-parseable structured format -- Ideal for log aggregation systems (Elasticsearch, Splunk, etc.) -- All fields as JSON key-value pairs -- Default format for production deployments - -**Example:** ```json -{"timestamp":"2026-01-09T12:30:45Z","level":"info","message":"Server started","component":"hyperfleet-api","version":"v1.2.3","hostname":"pod-abc","request_id":"2C9zKDz8xQMqF3yH","port":8000} +{"timestamp":"2026-09-14T18:00:00Z","level":"info","message":"Resource updated","component":"api","version":"v1.2.3","hostname":"pod-abc","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"00f067aa0ba902b7","resource_type":"Cluster","resource_id":"cluster-123","request_id":"0199458e-c331-7a22-8443-e25a91e95d81","adapter":"example"} ``` -### Text Format (`LOG_FORMAT=text`) - -**Characteristics:** -- Human-readable format following HyperFleet Logging Specification -- Format: `{timestamp} {LEVEL} [{component}] [{version}] [{hostname}] {message} {key=value}...` -- System fields (`component`, `version`, `hostname`) in brackets for clarity -- Level in uppercase for quick visual scanning -- Ideal for local development and real-time log monitoring +Only context fields actually present on the supplied context are emitted. -**Example:** -```text -2026-01-09T12:30:45Z INFO [hyperfleet-api] [v1.2.3] [pod-abc] Server started request_id=2C9zKDz8xQMqF3yH port=8000 -``` +### Text -### Side-by-Side Comparison +Text output has this shape: -**Same log statement:** -```go -logger.With(ctx, "cluster_id", "cluster-abc123", "region", "us-east-1").Info("Processing cluster creation") +```text +{timestamp} {LEVEL} [{component}] [{version}] [{hostname}] {message} {key=value}... ``` -**JSON output:** -```json -{"timestamp":"2026-01-09T12:30:45Z","level":"info","source":{"function":"main.handler","file":"/app/main.go","line":45},"message":"Processing cluster creation","component":"hyperfleet-api","version":"v1.2.3","hostname":"pod-abc","request_id":"2C9zKDz8xQMqF3yH","cluster_id":"cluster-abc123","region":"us-east-1"} -``` +For example: -**Text output:** ```text -2026-01-09T12:30:45Z INFO [hyperfleet-api] [v1.2.3] [pod-abc] Processing cluster creation request_id=2C9zKDz8xQMqF3yH cluster_id=cluster-abc123 region=us-east-1 +2026-09-14T18:00:00Z INFO [api] [v1.2.3] [pod-abc] Resource updated trace_id=4bf92f3577b34da6a3ce929d0e0e4736 span_id=00f067aa0ba902b7 resource_type=Cluster resource_id=cluster-123 request_id=0199458e-c331-7a22-8443-e25a91e95d81 adapter=example ``` -### Switching Between Formats - -Toggle between formats using the `HYPERFLEET_LOGGING_FORMAT` environment variable (`json` or `text`). No code changes required - the logger automatically adapts output format based on configuration. +The API enables the shared handler's text sanitization. Newlines and other +control characters in messages and attribute values are escaped so +client-controlled values cannot forge additional log lines. -See the [Configuration](#configuration) section above for examples. +## Errors and stack traces -## Database Logging +Stack capture preserves the behavior of the previous API handlers: -HyperFleet API automatically integrates database (GORM) logging with the application's `LOG_LEVEL` configuration while providing a `DB_DEBUG` override for database-specific debugging. +- In JSON format, every emitted record at `ERROR` or above includes `stack_trace`, + regardless of the configured logging level. +- In text format, stack traces are not captured automatically. +- Records below `ERROR` never receive a captured stack trace. Handled HTTP client + failures use `INFO` or `WARN`. -### HYPERFLEET_LOGGING_LEVEL Integration +Log failures with their error value; no additional classification attribute is +required: -Database logs follow the application log level by default: +```go +slog.ErrorContext(ctx, + "Database operation failed", + "error", err, +) +``` -| HYPERFLEET_LOGGING_LEVEL | GORM Behavior | What Gets Logged | -|--------------------------|---------------|------------------| -| `debug` | Info level | All SQL queries with parameters, duration, and row counts | -| `info` | Warn level | Only slow queries (>200ms) and errors | -| `warn` | Warn level | Only slow queries (>200ms) and errors | -| `error` | Silent | Nothing (database logging disabled) | +Recovered startup panics separately include the recovered `panic_stack` captured +by `runtime/debug`, in either format. -### HYPERFLEET_DATABASE_DEBUG Override +## HTTP request logging -The `HYPERFLEET_DATABASE_DEBUG` environment variable provides database-specific debugging without changing the global `HYPERFLEET_LOGGING_LEVEL`: +The request logger emits two `INFO` records for each API request except +`/healthcheck` (with or without a trailing slash): -```bash -# Production environment with database debugging -export HYPERFLEET_LOGGING_LEVEL=info # Application logs remain at INFO -export HYPERFLEET_LOGGING_FORMAT=json # Production format -export HYPERFLEET_DATABASE_DEBUG=true # Force all SQL queries to be logged -./bin/hyperfleet-api serve -``` +- `HTTP request received` with `method`, `path`, `remote_addr`, `user_agent`, + and masked request `headers` +- `HTTP request completed` with `method`, `path`, `status_code`, `duration_ms`, + `remote_addr`, and `user_agent` -**Priority:** -1. If `HYPERFLEET_DATABASE_DEBUG=true`, all SQL queries are logged (GORM Info level) -2. Otherwise, follow `HYPERFLEET_LOGGING_LEVEL` mapping (see table above) +Because request ID and OpenTelemetry middleware run first, these records also +carry `request_id` and, when tracing is enabled, `trace_id` and `span_id`. -### Database Log Examples +The middleware deliberately does not log request or response bodies. If code +has a justified need to log a body, it must call `MaskingMiddleware.MaskBody` +before logging it and should still prefer a small allowlist of safe fields. -**Fast query (LOG_LEVEL=debug or DB_DEBUG=true):** +## Data masking -JSON format: -```json -{ - "timestamp": "2026-01-14T11:31:29.788683+08:00", - "level": "info", - "message": "GORM query", - "duration_ms": 9.052167, - "rows": 1, - "sql": "INSERT INTO \"clusters\" (\"id\",\"created_time\",...) VALUES (...)", - "component": "api", - "version": "0120ac6-modified", - "hostname": "yasun-mac", - "request_id": "38EOuujxBDUduP0hYLxVGMm69Dq", - "transaction_id": 1157 -} -``` +Masking is enabled by default. The default sensitive headers are: -Text format: -```text -2026-01-14T11:34:23+08:00 INFO [api] [0120ac6-modified] [yasun-mac] GORM query request_id=38EPGnassU9SLNZ82XiXZLiWS4i duration_ms=10.135875 rows=1 sql="INSERT INTO \"clusters\" (\"id\",\"created_time\",...) VALUES (...)" -``` +- `Authorization` +- `X-API-Key` +- `Cookie` +- `X-Auth-Token` +- `X-Forwarded-Authorization` -**Slow query (>200ms, visible at all log levels except error):** +Header matching is case-insensitive. Sensitive header values are replaced by +`***REDACTED***` in the request-start record. -```json -{ - "timestamp": "2026-01-14T12:00:00Z", - "level": "warn", - "message": "GORM query", - "duration_ms": 250.5, - "rows": 1000, - "sql": "SELECT * FROM clusters WHERE ...", - "request_id": "...", - "transaction_id": 1234 -} -``` +Body-field matching is case-insensitive and recursively traverses JSON objects +and arrays. For invalid or oversized JSON, `MaskBody` applies best-effort text +fallback masking. -**Database error (visible at all log levels):** +To replace the header list through an environment variable, use a +comma-separated value: -```json -{ - "timestamp": "2026-01-14T12:00:00Z", - "level": "error", - "message": "GORM query error", - "error": "pq: duplicate key value violates unique constraint \"idx_clusters_name\"", - "duration_ms": 10.5, - "rows": 0, - "sql": "INSERT INTO \"clusters\" ...", - "request_id": "..." -} +```bash +export HYPERFLEET_LOGGING_MASKING_HEADERS='Authorization,Cookie,X-Custom-Auth' ``` -### Configuration Priority +Disabling masking makes logged request headers visible. Do so only in a +controlled environment with non-sensitive traffic. -The `HYPERFLEET_DATABASE_DEBUG` environment variable takes precedence over the global logging level. When `HYPERFLEET_DATABASE_DEBUG` is not set, database logging automatically follows `HYPERFLEET_LOGGING_LEVEL`. +## Database logging -## Log Output Examples +The GORM adapter emits structured records using the request context, so query +records can include request, trace, and resource correlation: -### Error Logs with Stack Traces +| Event | Level | Fields | +| --- | --- | --- | +| Normal query | `INFO` | `duration_ms`, `rows`, `sql` | +| Query slower than 200 ms | `WARN` | `duration_ms`, `threshold_ms`, `rows`, `sql` | +| Query failure other than record-not-found | `ERROR` | `error`, `duration_ms`, `rows`, `sql` | -**Code**: -```go -logger.With(ctx, "host", "postgres.svc").WithError(err).Error("Failed to connect to database") -``` +For the serving API, GORM verbosity is selected as follows: -**JSON Output**: -```json -{ - "timestamp": "2026-01-09T12:30:45Z", - "level": "error", - "source": {"function": "db.Connect", "file": "/app/db/connection.go", "line": 45}, - "message": "Failed to connect to database", - "host": "postgres.svc", - "error": "dial tcp: lookup postgres.svc: no such host", - "component": "hyperfleet-api", - "version": "v1.2.3", - "hostname": "pod-abc", - "request_id": "2C9zKDz8xQMqF3yH", - "stack_trace": [ - "/workspace/pkg/db/connection.go:45 github.com/openshift-hyperfleet/hyperfleet-api/pkg/db.Connect", - "/workspace/pkg/db/factory.go:78 github.com/openshift-hyperfleet/hyperfleet-api/pkg/db.NewSessionFactory", - "/workspace/cmd/hyperfleet-api/servecmd/cmd.go:123 main.setupDatabase" - ] -} -``` +| Configuration | GORM mode | Records offered to `slog` | +| --- | --- | --- | +| `database.debug=true` | Info | All queries, slow queries, and errors | +| Otherwise, `logging.level=debug` | Info | All queries, slow queries, and errors | +| Otherwise, `logging.level=info` or `warn` | Warn | Slow queries and errors | +| Otherwise, `logging.level=error` | Silent | No GORM records | -**Text Output** (multi-line stack trace for readability): -```text -2026-01-09T12:30:45Z ERROR [hyperfleet-api] [v1.2.3] [pod-abc] Failed to connect to database request_id=2C9zKDz8xQMqF3yH host=postgres.svc error="dial tcp: lookup postgres.svc: no such host" - stack_trace: - /workspace/pkg/db/connection.go:45 github.com/openshift-hyperfleet/hyperfleet-api/pkg/db.Connect - /workspace/pkg/db/factory.go:78 github.com/openshift-hyperfleet/hyperfleet-api/pkg/db.NewSessionFactory - /workspace/cmd/hyperfleet-api/servecmd/cmd.go:123 main.setupDatabase -``` +The global `slog` level still filters GORM records. For example, +`database.debug=true` with `logging.level=warn` does not make normal +`INFO`-level query records visible. Use `logging.level=debug` or `info` while +temporarily enabling database debug if all SQL is required. -**Note**: Error-level logs automatically include stack traces. +```bash +HYPERFLEET_LOGGING_LEVEL=info \ +HYPERFLEET_DATABASE_DEBUG=true \ +./bin/hyperfleet-api serve +``` -## OpenTelemetry Integration +SQL records may include query arguments. Enable full query logging only for +short-lived diagnosis and avoid storing production query logs where sensitive +data could be exposed. -### Initialization +## OpenTelemetry integration -OpenTelemetry is initialized in `cmd/hyperfleet-api/servecmd/cmd.go` (see `runServe()` function, lines ~74-110). +Tracing has its own application configuration, separate from logging: -**Key behavior:** -- Checks `HYPERFLEET_TRACING_ENABLED` environment variable first (tracing standard) -- Falls back to config file setting if not set -- Uses `OTEL_SERVICE_NAME` if set, otherwise defaults to `"hyperfleet-api"` -- Initializes trace provider via `telemetry.InitTraceProvider(ctx, serviceName, api.Version)` -- Shuts down with timeout during graceful shutdown +```yaml +tracing: + enabled: true + service_name: hyperfleet-api +``` -See the actual implementation for complete error handling and shutdown logic. +The application default is enabled. The Helm chart may supply a different value +through `HYPERFLEET_TRACING_ENABLED`. The former +`logging.otel.enabled` configuration key is accepted for compatibility but is +deprecated; `tracing.enabled` wins when both are set. -### Trace Propagation +### Environment variables -The OTel middleware automatically: -1. Extracts W3C trace context from incoming HTTP headers -2. Creates or continues spans for each request -3. Injects trace_id and span_id into the logger context -4. Exports traces to stdout (can be configured for other exporters) +| Variable | Purpose | Application default | +| --- | --- | --- | +| `HYPERFLEET_TRACING_ENABLED` | Enables provider setup and HTTP tracing middleware | `true` | +| `OTEL_SERVICE_NAME` | Service name attached to spans | `hyperfleet-api` | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector endpoint; absence selects the stdout trace exporter | unset | +| `OTEL_EXPORTER_OTLP_PROTOCOL` | `grpc` or `http/protobuf` | `grpc` | +| `OTEL_TRACES_SAMPLER` | Sampling strategy | `parentbased_traceidratio` | +| `OTEL_TRACES_SAMPLER_ARG` | Ratio from `0.0` to `1.0` for ratio samplers | `1.0` | +| `OTEL_PROPAGATORS` | Comma-separated propagation formats | `tracecontext,baggage` | +| `OTEL_RESOURCE_ATTRIBUTES` | Extra resource attributes as `key=value` pairs | unset | -### Sampling +Supported sampler values are `always_on`, `always_off`, `traceidratio`, +`parentbased_always_on`, `parentbased_always_off`, and +`parentbased_traceidratio`. An unknown sampler or invalid ratio falls back to +the default sampler/rate and emits a warning. -By default, all traces are sampled (`parentbased_always_on`). To reduce overhead, switch to ratio-based sampling: +For a production collector with 10% root-span sampling: ```bash +export HYPERFLEET_TRACING_ENABLED=true +# Use HTTPS for TLS in production; HTTP is suitable for local development only. +export OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.com:4317 +export OTEL_EXPORTER_OTLP_PROTOCOL=grpc export OTEL_TRACES_SAMPLER=parentbased_traceidratio -export OTEL_TRACES_SAMPLER_ARG=0.1 # 10% of root spans +export OTEL_TRACES_SAMPLER_ARG=0.1 ``` -**Sampler types:** -- `parentbased_always_on`: Sample all root spans (default) -- `parentbased_traceidratio`: Sample a percentage of root spans (set rate via `OTEL_TRACES_SAMPLER_ARG`) -- `always_on` / `always_off`: Sample all or none, ignoring parent context +The HTTP middleware extracts standard W3C trace context, creates or continues a +span, and places its IDs in logging context. Route templates are used for span +names when available to avoid high-cardinality names. The trace provider is +flushed during graceful shutdown. -## Data Masking +If no OTLP endpoint is configured while tracing is enabled, spans are written +by the stdout trace exporter. Set an endpoint or disable tracing when that +additional stdout output is not wanted. -Sensitive data is automatically masked when `MASKING_ENABLED=true`: +## Testing logging behavior -**Default masked headers**: `Authorization`, `Cookie`, `X-API-Key`, `X-Auth-Token` -**Default masked fields**: `password`, `token`, `secret`, `api_key`, `client_secret` - -To add custom masking rules to a loaded `*config.ApplicationConfig` named `cfg`: +Construct an isolated logger backed by a buffer and assert decoded fields. Do +not compare complete records containing timestamps or generated request IDs. ```go -cfg.Logging.Masking.Headers = append( - cfg.Logging.Masking.Headers, - "X-Custom-Auth-Header", -) - -cfg.Logging.Masking.Fields = append( - cfg.Logging.Masking.Fields, - "credit_card", - "ssn", -) +func TestResourceLog(t *testing.T) { + var output bytes.Buffer + log := logger.NewLogger("test-version", logger.HandlerConfig{ + Level: slog.LevelInfo, + Format: hfl.FormatJSON, + Output: &output, + Hostname: "test-host", + }) + + ctx := hfl.WithResourceType(context.Background(), "Cluster") + ctx = hfl.WithResourceID(ctx, "cluster-123") + ctx, err := logger.WithRequestID(ctx) + if err != nil { + t.Fatal(err) + } + log.InfoContext(ctx, "Resource updated") + + var record map[string]any + if err := json.Unmarshal(output.Bytes(), &record); err != nil { + t.Fatal(err) + } + if got := record["component"]; got != "api" { + t.Fatalf("component = %v, want api", got) + } + if got := record["resource_id"]; got != "cluster-123" { + t.Fatalf("resource_id = %v, want cluster-123", got) + } + if _, ok := record["request_id"].(string); !ok { + t.Fatalf("request_id = %T, want string", record["request_id"]) + } +} ``` -## Best Practices - -### Application Logging - -1. **Always use context**: `logger.Info(ctx, "msg")` not `slog.Info("msg")` -2. **Use WithError for errors**: `logger.WithError(ctx, err).Error(...)` not `"error", err` -3. **Use field constants**: `logger.FieldEnvironment` not `"environment"` -4. **Use HTTP helpers**: `logger.HTTPMethod(r.Method)` not `"method", r.Method` -5. **Chain for readability**: `logger.With(ctx, ...).WithError(err).Error(...)` -6. **Never log sensitive data**: Always sanitize passwords, tokens, connection strings -7. **Choose appropriate levels**: DEBUG (dev), INFO (normal), WARN (client error), ERROR (server error) - -### Database Logging - -1. **Use HYPERFLEET_LOGGING_LEVEL for database logs**: Don't set `HYPERFLEET_DATABASE_DEBUG` unless specifically debugging database issues -2. **Production default**: `HYPERFLEET_LOGGING_LEVEL=info` hides fast queries, shows slow queries (>200ms) -3. **Temporary debugging**: Use `HYPERFLEET_DATABASE_DEBUG=true` for production database troubleshooting, then disable it -4. **Development**: Use `HYPERFLEET_LOGGING_LEVEL=debug` to see all SQL queries during development -5. **High-traffic systems**: Consider `HYPERFLEET_LOGGING_LEVEL=warn` to minimize database log volume -6. **Monitor slow queries**: Review WARN-level GORM logs for queries exceeding 200ms threshold - -## Troubleshooting - -### Logs Not Appearing - -1. Check log level: `export HYPERFLEET_LOGGING_LEVEL=debug` -2. Verify text mode: `export HYPERFLEET_LOGGING_FORMAT=text` (for human-readable output) -3. Check context propagation: Ensure middleware chain is correct - -### Missing request_id - -Verify `RequestIDMiddleware` is registered before `RequestLoggingMiddleware`: +Tests that must replace the process-wide logger should restore it: ```go -mainRouter.Use(logger.RequestIDMiddleware) -mainRouter.Use(middleware.OTelMiddleware) -mainRouter.Use(logging.RequestLoggingMiddleware) +previous := slog.Default() +slog.SetDefault(testLogger) +t.Cleanup(func() { slog.SetDefault(previous) }) ``` -### Missing trace_id/span_id +Useful commands are: -1. Check tracing is enabled: `export HYPERFLEET_TRACING_ENABLED=true` -2. Verify middleware order: `OTelMiddleware` must be after `RequestIDMiddleware` -3. Check sampling rate: `export OTEL_TRACES_SAMPLER_ARG=1.0` (for testing - trace all requests) - -### Data Not Masked - -1. Check masking is enabled: `export HYPERFLEET_LOGGING_MASKING_ENABLED=true` -2. Verify field names match configuration (case-insensitive) -3. Check JSON structure: Masking only works on top-level fields +```bash +HYPERFLEET_LOGGING_LEVEL=debug make test +HYPERFLEET_TRACING_ENABLED=false make test-integration +``` -### SQL Queries Not Appearing +## Troubleshooting -1. Check log level: `export HYPERFLEET_LOGGING_LEVEL=debug` (to see all SQL queries) -2. Check database debug: `export HYPERFLEET_DATABASE_DEBUG=true` (to force SQL logging at any log level) -3. Verify queries are executing: Check if API operations complete successfully -4. Check log format: Use `HYPERFLEET_LOGGING_FORMAT=text` for easier debugging +### Logs do not appear -### Too Many SQL Queries in Logs +- Confirm that the record level is at or above `HYPERFLEET_LOGGING_LEVEL`. +- Confirm whether `HYPERFLEET_LOGGING_OUTPUT` sends records to `stdout` or + `stderr`. +- Use text/debug mode locally to make filtering easier. -1. Production mode: `export HYPERFLEET_LOGGING_LEVEL=info` (hides fast queries < 200ms) -2. Disable database debug: `export HYPERFLEET_DATABASE_DEBUG=false` or unset it -3. Minimal mode: `export HYPERFLEET_LOGGING_LEVEL=warn` (only slow queries and errors) -4. Silent mode: `export HYPERFLEET_LOGGING_LEVEL=error` (no SQL queries logged) +### `request_id` is missing -### Only Want to See Slow Queries +- Use a `slog.*Context` method with the request-derived context. +- Confirm `RequestIDMiddleware` wraps the handler that emits the record. +- Do not replace the request context with `context.Background()` downstream. -Use production default configuration: -```bash -export HYPERFLEET_LOGGING_LEVEL=info -export HYPERFLEET_LOGGING_FORMAT=json -export HYPERFLEET_DATABASE_DEBUG=false # or leave unset -``` +### `trace_id` or `span_id` is missing -This will only log SQL queries that take longer than 200ms. +- Confirm `HYPERFLEET_TRACING_ENABLED=true`. +- Confirm the log uses the context passed through `OTelMiddleware`. +- For local diagnosis, use `OTEL_TRACES_SAMPLER_ARG=1.0`. -## Testing +### A sensitive header is visible -### Unit Tests +- Confirm `HYPERFLEET_LOGGING_MASKING_ENABLED=true`. +- Confirm the header appears in `HYPERFLEET_LOGGING_MASKING_HEADERS`; replacing + this variable replaces the configured list. +- Remember that application log call sites outside request logging must choose + and sanitize their own attributes. -```go -func TestLogging(t *testing.T) { - // Create context with request ID - ctx, err := logger.WithRequestID(context.Background()) - if err != nil { - t.Fatalf("Failed to create request ID: %v", err) - } - - // Log with context - logger.With(ctx, "key", "value").Info("Test message") - - // Verify request_id is included - // (Use a test handler to capture logs) -} -``` +### SQL queries do not appear -### Integration Tests +- Set `HYPERFLEET_LOGGING_LEVEL=debug` to enable and display normal queries. +- Alternatively, set `HYPERFLEET_DATABASE_DEBUG=true` with an overall level of + `debug` or `info`. +- At `info` or `warn` without database debug, only queries slower than 200 ms + and query errors are offered by GORM. +- At `error` without database debug, GORM logging is silent. -```bash -# Run tests with debug logging -HYPERFLEET_LOGGING_LEVEL=debug go test ./test/integration/... +### Too many SQL queries appear -# Run tests without OTel -HYPERFLEET_TRACING_ENABLED=false go test ./... -``` +- Disable `HYPERFLEET_DATABASE_DEBUG`. +- Use `HYPERFLEET_LOGGING_LEVEL=info` for slow queries and errors without normal + query records. +- Use `warn` to suppress other informational application records as well. ## References -- [slog Documentation](https://pkg.go.dev/log/slog) -- [OpenTelemetry Go SDK](https://opentelemetry.io/docs/languages/go/) +- [Application configuration](config.md) +- [Go `log/slog` package](https://pkg.go.dev/log/slog) +- [OpenTelemetry Go documentation](https://opentelemetry.io/docs/languages/go/) - [W3C Trace Context](https://www.w3.org/TR/trace-context/) -- [HyperFleet Architecture](https://github.com/openshift-hyperfleet/architecture) +- [HyperFleet logging specification](https://github.com/openshift-hyperfleet/architecture/blob/main/hyperfleet/standards/logging-specification.md) +- [HyperFleet tracing standard](https://github.com/openshift-hyperfleet/architecture/blob/main/hyperfleet/standards/tracing.md) diff --git a/go.mod b/go.mod index a1833520..6e5363c1 100755 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/oapi-codegen/runtime v1.7.0 github.com/onsi/gomega v1.43.0 github.com/openshift-hyperfleet/hyperfleet-api-spec v1.0.27 + github.com/openshift-hyperfleet/hyperfleet-logger v0.0.0-20260811173525-c9f9e282d029 github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_model v0.6.2 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index b53fc196..407d8615 100644 --- a/go.sum +++ b/go.sum @@ -190,6 +190,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/openshift-hyperfleet/hyperfleet-api-spec v1.0.27 h1:wtLN7KFgsDHaYDBElMcOUjHVeqjRs6dlGOWiobS1/qk= github.com/openshift-hyperfleet/hyperfleet-api-spec v1.0.27/go.mod h1:KITzIAd8HcMpH5lXdHFjgk45dvL6XLpP3wwz8iK+KCI= +github.com/openshift-hyperfleet/hyperfleet-logger v0.0.0-20260811173525-c9f9e282d029 h1:c3GdD3EUdR9lRjNot+aBIxVbAXbOSTtiQpWVTHnFHaU= +github.com/openshift-hyperfleet/hyperfleet-logger v0.0.0-20260811173525-c9f9e282d029/go.mod h1:5Nh2IMS2MouehZ4UiRXFMjhuwdys2DX26J4vArmXe6Y= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/pkg/api/error.go b/pkg/api/error.go index bffb6ba8..4bfdccb9 100755 --- a/pkg/api/error.go +++ b/pkg/api/error.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "net/http" "os" "time" @@ -34,7 +35,7 @@ func SendNotFound(w http.ResponseWriter, r *http.Request) { data, err := json.Marshal(body) if err != nil { - logger.WithError(r.Context(), err).Error("Failed to marshal not found response") + slog.ErrorContext(r.Context(), "Failed to marshal not found response", "error", err) SendPanic(w, r) return } @@ -43,7 +44,7 @@ func SendNotFound(w http.ResponseWriter, r *http.Request) { _, err = w.Write(data) if err != nil { err = fmt.Errorf("can't send response body for request '%s'", r.URL.Path) - logger.WithError(r.Context(), err).Error("Failed to send response") + slog.ErrorContext(r.Context(), "Failed to send response", "error", err) } } @@ -84,7 +85,7 @@ func SendPanic(w http.ResponseWriter, r *http.Request) { r.URL.Path, err.Error(), ) - logger.WithError(r.Context(), err).Error("Failed to send panic response") + slog.ErrorContext(r.Context(), "Failed to send panic response", "error", err) } } @@ -111,7 +112,7 @@ func init() { panicBody, err = json.Marshal(panicError) if err != nil { err = fmt.Errorf("can't create the panic error body: %s", err.Error()) - logger.WithError(ctx, err).Error("Failed to create panic error body") + slog.ErrorContext(ctx, "Failed to create panic error body", "error", err) os.Exit(1) } } diff --git a/pkg/api/response/problem_details.go b/pkg/api/response/problem_details.go index a812950a..6785abda 100644 --- a/pkg/api/response/problem_details.go +++ b/pkg/api/response/problem_details.go @@ -3,6 +3,7 @@ package response import ( "context" "encoding/json" + "log/slog" "net/http" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" @@ -29,9 +30,8 @@ func WriteProblemDetailsResponse(w http.ResponseWriter, r *http.Request, code in } func logResponseError(ctx context.Context, r *http.Request, code int, message string, err error) { - logger.With(ctx, - logger.HTTPPath(r.URL.Path), + slog.ErrorContext(ctx, + message, logger.HTTPPath(r.URL.Path), logger.HTTPMethod(r.Method), - logger.HTTPStatusCode(code), - ).WithError(err).Error(message) + logger.HTTPStatusCode(code), "error", err) } diff --git a/pkg/auth/helpers.go b/pkg/auth/helpers.go index 580b03ab..bd414d8f 100755 --- a/pkg/auth/helpers.go +++ b/pkg/auth/helpers.go @@ -2,6 +2,7 @@ package auth import ( "context" + "log/slog" "net/http" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/response" @@ -20,9 +21,9 @@ func handleError(ctx context.Context, w http.ResponseWriter, r *http.Request, co instance = r.URL.Path } if err.HTTPCode >= 400 && err.HTTPCode <= 499 { - logger.WithError(ctx, err).Warn("Client error occurred") + slog.WarnContext(ctx, "Client error occurred", "error", err) } else { - logger.WithError(ctx, err).Error("Server error occurred") + slog.ErrorContext(ctx, "Server error occurred", "error", err) } response.WriteProblemDetailsResponse(w, r, err.HTTPCode, err.AsProblemDetails(instance, traceID)) diff --git a/pkg/auth/helpers_test.go b/pkg/auth/helpers_test.go new file mode 100644 index 00000000..c7239708 --- /dev/null +++ b/pkg/auth/helpers_test.go @@ -0,0 +1,49 @@ +package auth + +import ( + "bytes" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + hfl "github.com/openshift-hyperfleet/hyperfleet-logger" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" +) + +func TestHandleError_OnlyServerFailuresIncludeStack(t *testing.T) { + for _, tt := range []struct { + name string + code string + status int + stack bool + }{ + {name: "missing credentials", code: errors.CodeAuthNoCredentials, status: http.StatusUnauthorized}, + {name: "internal failure", code: errors.CodeInternalGeneral, status: http.StatusInternalServerError, stack: true}, + } { + t.Run(tt.name, func(t *testing.T) { + var output bytes.Buffer + previous := slog.Default() + slog.SetDefault(logger.NewLogger("test", logger.HandlerConfig{ + Level: slog.LevelInfo, Format: hfl.FormatJSON, Output: &output, + })) + t.Cleanup(func() { slog.SetDefault(previous) }) + req := httptest.NewRequest(http.MethodGet, "/test", nil).WithContext(t.Context()) + res := httptest.NewRecorder() + handleError(req.Context(), res, req, tt.code, "test error") + if res.Code != tt.status { + t.Errorf("HTTP status = %d, want %d", res.Code, tt.status) + } + var record map[string]any + if err := json.Unmarshal(output.Bytes(), &record); err != nil { + t.Fatal(err) + } + if _, ok := record["stack_trace"]; ok != tt.stack { + t.Errorf("stack present = %v, want %v", ok, tt.stack) + } + }) + } +} diff --git a/pkg/auth/jwt_handler.go b/pkg/auth/jwt_handler.go index 39a21927..d887098f 100644 --- a/pkg/auth/jwt_handler.go +++ b/pkg/auth/jwt_handler.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "net/http" "os" "strings" @@ -18,7 +19,6 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" hferrors "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) const ( @@ -149,7 +149,7 @@ func (h *JWTHandler) Middleware(next http.Handler) http.Handler { // No validator matched - return the most appropriate error if lastErr != nil { - logger.WithError(r.Context(), lastErr).Warn("JWT validation failed") + slog.WarnContext(r.Context(), "JWT validation failed", "error", lastErr) if errors.Is(lastErr, jwt.ErrTokenExpired) { handleError(r.Context(), w, r, hferrors.CodeAuthExpiredToken, "JWT token has expired") } else { @@ -270,14 +270,15 @@ func newStorageWithCA(ctx context.Context, jwkURL, caFile string) (jwkset.Storag if err != nil { return nil, err } - logger.With(ctx, "url", jwkURL, "ca_file", caFile).Info("JWKS client configured with custom CA") + slog.InfoContext(ctx, "JWKS client configured with custom CA", "url", jwkURL, "ca_file", caFile) storage, err := jwkset.NewStorageFromHTTP(jwkURL, jwkset.HTTPClientStorageOptions{ Client: httpClient, Ctx: ctx, NoErrorReturnFirstHTTPReq: true, RefreshErrorHandler: func(ctx context.Context, err error) { - logger.With(ctx, "url", jwkURL, "ca_file", caFile).WithError(err). - Error("failed to refresh JWKS from URL with custom CA") + slog.ErrorContext(ctx, + "failed to refresh JWKS from URL with custom CA", "url", jwkURL, "ca_file", caFile, "error", err, + ) }, RefreshInterval: defaultJWKSRefreshInterval, }) diff --git a/pkg/closer/closer.go b/pkg/closer/closer.go index c0d3ba47..1c7f2fc1 100644 --- a/pkg/closer/closer.go +++ b/pkg/closer/closer.go @@ -4,10 +4,9 @@ import ( "context" "errors" "fmt" + "log/slog" "sync" "time" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) type Closer struct { @@ -47,10 +46,10 @@ func (c *Closer) Close() error { err := fns[i]() elapsed := time.Since(start) if err != nil { - logger.With(ctx, "step", i, "duration", elapsed).WithError(err).Error("closer: step failed") + slog.ErrorContext(ctx, "closer: step failed", "step", i, "duration", elapsed, "error", err) joined = errors.Join(joined, fmt.Errorf("step %d: %w", i, err)) } else { - logger.With(ctx, "step", i, "duration", elapsed).Info("closer: step completed") + slog.InfoContext(ctx, "closer: step completed", "step", i, "duration", elapsed) } } diff --git a/pkg/config/flags.go b/pkg/config/flags.go index 0cdc1c34..484f9d93 100644 --- a/pkg/config/flags.go +++ b/pkg/config/flags.go @@ -52,7 +52,7 @@ func AddDatabaseFlags(cmd *cobra.Command) { } // AddLoggingFlags adds logging configuration flags following standard naming -// Format: --log- maps to HYPERFLEET_LOGGING_ and logging. +// Format: --log- maps to HYPERFLEET_LOGGING_ and logging.. func AddLoggingFlags(cmd *cobra.Command) { defaults := NewLoggingConfig() diff --git a/pkg/config/loader.go b/pkg/config/loader.go index a8430391..5b188c2c 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -3,6 +3,7 @@ package config import ( "context" "fmt" + "log/slog" "os" "reflect" "strings" @@ -11,8 +12,6 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) // ConfigLoader handles loading and validating application configuration @@ -72,7 +71,6 @@ func (l *ConfigLoader) Load(ctx context.Context, cmd *cobra.Command) (*Applicati "configuration unmarshal failed: %w\nThis usually means unknown/misspelled fields in config file", err) } - // Step 6.5: Migrate deprecated configuration before validation l.migrateDeprecatedConfig(ctx, config) @@ -104,7 +102,7 @@ func (l *ConfigLoader) resolveAndReadConfigFile(ctx context.Context, cmd *cobra. return err } explicitPath = true - logger.With(ctx, "config_path", configPath, "source", "flag").Info("Config file specified via --config flag") + slog.InfoContext(ctx, "Config file specified via --config flag", "config_path", configPath, "source", "flag") } // Priority 2: HYPERFLEET_CONFIG environment variable @@ -112,7 +110,7 @@ func (l *ConfigLoader) resolveAndReadConfigFile(ctx context.Context, cmd *cobra. if envPath := os.Getenv("HYPERFLEET_CONFIG"); envPath != "" { configPath = envPath explicitPath = true - logger.With(ctx, "config_path", configPath, "source", "env").Info("Config file specified via HYPERFLEET_CONFIG") + slog.InfoContext(ctx, "Config file specified via HYPERFLEET_CONFIG", "config_path", configPath, "source", "env") } } @@ -122,22 +120,24 @@ func (l *ConfigLoader) resolveAndReadConfigFile(ctx context.Context, cmd *cobra. prodPath := "/etc/hyperfleet/config.yaml" if _, err := os.Stat(prodPath); err == nil { configPath = prodPath - logger.With(ctx, "config_path", configPath, "source", "default_production"). - Info("Using production default config file") + slog.InfoContext(ctx, + "Using production default config file", "config_path", configPath, "source", "default_production", + ) } else { // Try development path devPath := "./configs/config.yaml" if _, err := os.Stat(devPath); err == nil { configPath = devPath - logger.With(ctx, "config_path", configPath, "source", "default_development"). - Info("Using development default config file") + slog.InfoContext(ctx, + "Using development default config file", "config_path", configPath, "source", "default_development", + ) } } } // If no config file found, continue with env vars and flags only if configPath == "" { - logger.Info(ctx, "No config file found, using environment variables and flags only") + slog.InfoContext(ctx, "No config file found, using environment variables and flags only") return nil } @@ -156,11 +156,11 @@ func (l *ConfigLoader) resolveAndReadConfigFile(ctx context.Context, cmd *cobra. return fmt.Errorf("failed to read config file %s: %w", configPath, err) } // Just log warning if using default path - logger.With(ctx, "config_path", configPath).WithError(err).Warn("Failed to read default config file, continuing") + slog.WarnContext(ctx, "Failed to read default config file, continuing", "config_path", configPath, "error", err) return nil } - logger.With(ctx, "config_path", configPath).Info("Successfully loaded config file") + slog.InfoContext(ctx, "Successfully loaded config file", "config_path", configPath) return nil } @@ -254,8 +254,10 @@ func (l *ConfigLoader) migrateDeprecatedConfig(ctx context.Context, config *Appl if tls.Enabled && tls.CertFile == "" && tls.KeyFile == "" { tls.CertFile = config.Server.TLS.CertFile tls.KeyFile = config.Server.TLS.KeyFile - logger.With(ctx, "server", name). - Warn("TLS enabled without cert/key - inheriting from server.tls (deprecated: set cert_file/key_file explicitly)") + slog.WarnContext(ctx, + "TLS enabled without cert/key - inheriting from server.tls (deprecated: set cert_file/key_file explicitly)", + "server", name, + ) } } propagateTLS("health", &config.Health.TLS) @@ -263,10 +265,10 @@ func (l *ConfigLoader) migrateDeprecatedConfig(ctx context.Context, config *Appl if l.viper.IsSet("logging.otel.enabled") { if l.viper.IsSet("tracing.enabled") { - logger.Warn(ctx, "logging.otel.enabled is deprecated and ignored because tracing.enabled is also set") + slog.WarnContext(ctx, "logging.otel.enabled is deprecated and ignored because tracing.enabled is also set") } else { config.Tracing.Enabled = config.Logging.OTel.Enabled - logger.Warn(ctx, "logging.otel.enabled is deprecated, use tracing.enabled instead") + slog.WarnContext(ctx, "logging.otel.enabled is deprecated, use tracing.enabled instead") } } } @@ -432,6 +434,7 @@ func (l *ConfigLoader) bindFlags(cmd *cobra.Command) { l.bindPFlag("logging.output", cmd.Flags().Lookup("log-output")) l.bindPFlag("logging.masking.enabled", cmd.Flags().Lookup("log-masking-enabled")) l.bindPFlag("logging.masking.headers", cmd.Flags().Lookup("log-masking-sensitive-headers")) + // Deprecated compatibility flag; the value is accepted but has no runtime effect. l.bindPFlag("logging.masking.fields", cmd.Flags().Lookup("log-masking-sensitive-fields")) // Metrics flags: --metrics-* -> metrics.* diff --git a/pkg/config/logging_test.go b/pkg/config/logging_test.go index 9eb1d521..2c7fec8d 100644 --- a/pkg/config/logging_test.go +++ b/pkg/config/logging_test.go @@ -21,7 +21,7 @@ func TestNewLoggingConfig_Defaults(t *testing.T) { Expect(cfg.Output).To(Equal("stdout")) Expect(cfg.Masking.Enabled).To(BeTrue()) Expect(cfg.Masking.Headers).NotTo(BeEmpty()) - Expect(cfg.Masking.Fields).NotTo(BeEmpty()) + Expect(cfg.Masking.Fields).NotTo(BeEmpty()) // Deprecated compatibility default. } // TestConfigLoader_LoggingFromEnv tests loading logging config from environment @@ -140,39 +140,6 @@ func TestLoggingConfig_GetSensitiveHeadersList(t *testing.T) { } } -// TestLoggingConfig_GetSensitiveFieldsList tests the fields array accessor -func TestLoggingConfig_GetSensitiveFieldsList(t *testing.T) { - RegisterTestingT(t) - - tests := []struct { - name string - input []string - expected []string - }{ - { - name: "standard list", - input: []string{"password", "secret", "token"}, - expected: []string{"password", "secret", "token"}, - }, - { - name: "empty array", - input: []string{}, - expected: []string{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := NewLoggingConfig() - cfg.Masking.Fields = tt.input - - fields := cfg.GetSensitiveFieldsList() - - Expect(fields).To(Equal(tt.expected)) - }) - } -} - // ============================================================== // Comprehensive Config Loader Tests // ============================================================== diff --git a/pkg/db/advisory_locks.go b/pkg/db/advisory_locks.go index 1a9c339c..3fda69f7 100644 --- a/pkg/db/advisory_locks.go +++ b/pkg/db/advisory_locks.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "hash/fnv" + "log/slog" "time" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" @@ -114,7 +115,7 @@ func (l *AdvisoryLock) unlock(ctx context.Context) error { l.g2 = nil if err == nil { - logger.With(ctx, logger.FieldLockDurationMs, duration.Milliseconds()).Info("Released advisory lock") + slog.InfoContext(ctx, "Released advisory lock", logger.FieldLockDurationMs, duration.Milliseconds()) } return err diff --git a/pkg/db/context.go b/pkg/db/context.go index 9fc37155..f5fd50a7 100755 --- a/pkg/db/context.go +++ b/pkg/db/context.go @@ -3,6 +3,7 @@ package db import ( "context" "errors" + "log/slog" "github.com/google/uuid" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/internal/txcontext" @@ -69,14 +70,14 @@ func NewAdvisoryLockContext( lock, err := newAdvisoryLock(ctx, connection, &lockOwnerID, &id, &lockType) if err != nil { - logger.WithError(ctx, err).Error("Failed to create advisory lock") + slog.ErrorContext(ctx, "Failed to create advisory lock", "error", err) return ctx, lockOwnerID, err } // obtain the advisory lock (blocking) err = lock.lock() if err != nil { - logger.WithError(ctx, err).Error("Failed to acquire advisory lock") + slog.ErrorContext(ctx, "Failed to acquire advisory lock", "error", err) lock.g2.Rollback() // clean up the open transaction return ctx, lockOwnerID, err } @@ -84,7 +85,7 @@ func NewAdvisoryLockContext( locks.set(id, lockType, lock) ctx = context.WithValue(ctx, advisoryLock, locks) - logger.With(ctx, logger.FieldLockID, id, logger.FieldLockType, lockType).Info("Acquired advisory lock") + slog.InfoContext(ctx, "Acquired advisory lock", logger.FieldLockID, id, logger.FieldLockType, lockType) return ctx, lockOwnerID, nil } @@ -93,13 +94,13 @@ func NewAdvisoryLockContext( func Unlock(ctx context.Context, callerUUID string) { locks, ok := ctx.Value(advisoryLock).(advisoryLockMap) if !ok { - logger.Error(ctx, "Could not retrieve locks from context") + slog.ErrorContext(ctx, "Could not retrieve locks from context") return } for k, lock := range locks { if lock.ownerUUID == nil { - logger.With(ctx, logger.FieldLockID, lock.id).Warn("lockOwnerID could not be found in AdvisoryLock") + slog.WarnContext(ctx, "lockOwnerID could not be found in AdvisoryLock", logger.FieldLockID, lock.id) } else if *lock.ownerUUID == callerUUID { lockID := "" lockType := LockType("") @@ -112,11 +113,11 @@ func Unlock(ctx context.Context, callerUUID string) { } if err := lock.unlock(ctx); err != nil { - logger.With(ctx, logger.FieldLockID, lockID, logger.FieldLockType, lockType). - WithError(err).Error("Could not unlock lock") + slog.ErrorContext(ctx, + "Could not unlock lock", logger.FieldLockID, lockID, logger.FieldLockType, lockType, "error", err) continue } - logger.With(ctx, logger.FieldLockID, lockID, logger.FieldLockType, lockType).Info("Unlocked lock") + slog.InfoContext(ctx, "Unlocked lock", logger.FieldLockID, lockID, logger.FieldLockType, lockType) delete(locks, k) } // Note: if ownerUUID doesn't match callerUUID, the lock belongs to a different diff --git a/pkg/db/db_session/default.go b/pkg/db/db_session/default.go index 6a5462a7..e9d2c733 100755 --- a/pkg/db/db_session/default.go +++ b/pkg/db/db_session/default.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "log/slog" "time" "gorm.io/driver/postgres" @@ -104,11 +105,10 @@ func (f *Default) Init(config *config.DatabaseConfig) { err.Error(), )) } - logger.With(context.Background(), - "retry", attempt+1, + slog.WarnContext(context.Background(), + "Database connection failed, retrying...", "retry", attempt+1, "max_retries", config.Pool.ConnRetryAttempts, - "retry_interval", config.Pool.ConnRetryInterval, - ).WithError(err).Warn("Database connection failed, retrying...") + "retry_interval", config.Pool.ConnRetryInterval, "error", err) time.Sleep(config.Pool.ConnRetryInterval) // Close the existing handle before re-opening to avoid leaking connections @@ -132,12 +132,12 @@ func (f *Default) Init(config *config.DatabaseConfig) { // Register database metrics GORM plugin if err = db_metrics.RegisterPlugin(g2); err != nil { - logger.WithError(context.Background(), err).Warn("Failed to register database metrics plugin") + slog.WarnContext(context.Background(), "Failed to register database metrics plugin", "error", err) } // Register connection pool metrics collector if err = db_metrics.RegisterPoolCollector(dbx); err != nil { - logger.WithError(context.Background(), err).Warn("Failed to register pool metrics collector") + slog.WarnContext(context.Background(), "Failed to register pool metrics collector", "error", err) } f.config = config @@ -159,19 +159,19 @@ func (f *Default) DirectDB() *sql.DB { return f.db } -func waitForNotification(l *pq.Listener, callback func(id string)) { - ctx := context.Background() +func waitForNotification(ctx context.Context, l *pq.Listener, callback func(id string)) { for { select { case n := <-l.Notify: - logger.With(ctx, logger.FieldChannel, n.Channel).With(logger.FieldData, n.Extra).Info("Received data from channel") + slog.InfoContext(ctx, "Received data from channel", + logger.FieldChannel, n.Channel, logger.FieldData, n.Extra) callback(n.Extra) return case <-time.After(10 * time.Second): - logger.Debug(ctx, "Received no events on channel during interval. Pinging source") + slog.DebugContext(ctx, "Received no events on channel during interval. Pinging source") go func() { if err := l.Ping(); err != nil { - logger.WithError(ctx, err).Debug("Ping failed") + slog.DebugContext(ctx, "Ping failed", "error", err) } }() return @@ -182,7 +182,7 @@ func waitForNotification(l *pq.Listener, callback func(id string)) { func newListener(ctx context.Context, connstr, channel string, callback func(id string)) { plog := func(ev pq.ListenerEventType, err error) { if err != nil { - logger.WithError(ctx, err).Error("PostgreSQL listener error") + slog.ErrorContext(ctx, "PostgreSQL listener error", "error", err) } } listener := pq.NewListener(connstr, 10*time.Second, time.Minute, plog) @@ -191,9 +191,9 @@ func newListener(ctx context.Context, connstr, channel string, callback func(id panic(err) } - logger.With(ctx, logger.FieldChannel, channel).Info("Starting channeling monitor") + slog.InfoContext(ctx, "Starting channeling monitor", logger.FieldChannel, channel) for { - waitForNotification(listener, callback) + waitForNotification(ctx, listener, callback) } } diff --git a/pkg/db/db_session/test.go b/pkg/db/db_session/test.go index 056636f0..7aba0368 100755 --- a/pkg/db/db_session/test.go +++ b/pkg/db/db_session/test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "log/slog" "time" "github.com/lib/pq" @@ -52,12 +53,12 @@ func (f *Test) Init(config *config.DatabaseConfig) { once.Do(func() { ctx := context.Background() if err := initDatabase(config, db.Migrate); err != nil { - logger.WithError(ctx, err).Error("Error initializing test database") + slog.ErrorContext(ctx, "Error initializing test database", "error", err) panic(fmt.Errorf("error initializing test database: %w", err)) } if err := resetDB(config); err != nil { - logger.WithError(ctx, err).Error("Error resetting test database") + slog.ErrorContext(ctx, "Error resetting test database", "error", err) panic(fmt.Errorf("error resetting test database: %w", err)) } }) diff --git a/pkg/db/migrations.go b/pkg/db/migrations.go index 9fd96c3a..0d8dce5a 100755 --- a/pkg/db/migrations.go +++ b/pkg/db/migrations.go @@ -2,12 +2,12 @@ package db import ( "context" + "log/slog" "github.com/go-gormigrate/gormigrate/v2" "gorm.io/gorm" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/migrations" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) // gormigrate is a wrapper for gorm's migration functions that adds schema versioning @@ -28,7 +28,7 @@ func MigrateWithLock(ctx context.Context, factory SessionFactory) error { // Acquire advisory lock for migrations ctx, lockOwnerID, err := NewAdvisoryLockContext(ctx, factory, MigrationsLockID, Migrations) if err != nil { - logger.WithError(ctx, err).Error("Could not lock migrations") + slog.ErrorContext(ctx, "Could not lock migrations", "error", err) return err } defer Unlock(ctx, lockOwnerID) @@ -36,11 +36,11 @@ func MigrateWithLock(ctx context.Context, factory SessionFactory) error { // Run migrations with the locked context g2 := factory.New(ctx) if err := Migrate(g2); err != nil { - logger.WithError(ctx, err).Error("Could not migrate") + slog.ErrorContext(ctx, "Could not migrate", "error", err) return err } - logger.Info(ctx, "Migration completed successfully") + slog.InfoContext(ctx, "Migration completed successfully") return nil } diff --git a/pkg/db/transactions.go b/pkg/db/transactions.go index 813f0e5f..b15507a5 100755 --- a/pkg/db/transactions.go +++ b/pkg/db/transactions.go @@ -5,13 +5,13 @@ import ( "database/sql" "errors" "fmt" + "log/slog" "github.com/prometheus/client_golang/prometheus" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/db_metrics" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db/internal/txcontext" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) // TransactionRunner executes callbacks inside database transactions. @@ -34,7 +34,7 @@ func (r *TransactionRunner) Do(ctx context.Context, callback func(context.Contex gormTx := r.connection.New(ctx).Begin() if gormTx.Error != nil { - logger.WithError(ctx, gormTx.Error).Error("Could not begin transaction") + slog.ErrorContext(ctx, "Could not begin transaction", "error", gormTx.Error) recordTransactionError("begin", "begin_failed") return fmt.Errorf("db: begin transaction: %w", gormTx.Error) } @@ -44,7 +44,7 @@ func (r *TransactionRunner) Do(ctx context.Context, callback func(context.Contex if !completed { rollbackErr := gormTx.Rollback().Error if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) { - logger.WithError(txCtx, rollbackErr).Error("Could not rollback transaction") + slog.ErrorContext(txCtx, "Could not rollback transaction", "error", rollbackErr) recordTransactionError("rollback", "rollback_failed") } } @@ -54,7 +54,7 @@ func (r *TransactionRunner) Do(ctx context.Context, callback func(context.Contex return fmt.Errorf("db: transaction callback: %w", err) } if err := gormTx.Commit().Error; err != nil { - logger.WithError(txCtx, err).Error("Could not commit transaction") + slog.ErrorContext(txCtx, "Could not commit transaction", "error", err) recordTransactionError("commit", "commit_failed") return fmt.Errorf("db: commit transaction: %w", err) } diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go index 237a43df..57e5b048 100755 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -3,6 +3,7 @@ package errors import ( "context" "fmt" + "log/slog" "net/http" "time" @@ -220,7 +221,7 @@ func New(code string, reason string, values ...interface{}) *ServiceError { exists, err := Find(code) if !exists { ctx := context.Background() - logger.With(ctx, logger.FieldErrorCode, code).Error("Undefined error code used") + slog.ErrorContext(ctx, "Undefined error code used", logger.FieldErrorCode, code) err = &ServiceError{ RFC9457Code: CodeInternalGeneral, Type: ErrorTypeInternal, @@ -373,7 +374,7 @@ func FailedToParseSearch(reason string, values ...interface{}) *ServiceError { func DatabaseAdvisoryLock(err error) *ServiceError { // Log the full error server-side for debugging ctx := context.Background() - logger.WithError(ctx, err).Error("Database advisory lock error") + slog.ErrorContext(ctx, "Database advisory lock error", "error", err) // Return a generic message to avoid leaking sensitive database info return New(CodeInternalDatabase, "internal database error") } diff --git a/pkg/handlers/helpers.go b/pkg/handlers/helpers.go index 72db7c3a..2b3ad25a 100755 --- a/pkg/handlers/helpers.go +++ b/pkg/handlers/helpers.go @@ -4,6 +4,7 @@ import ( "encoding/json" goerrors "errors" "io" + "log/slog" "net/http" "reflect" @@ -28,20 +29,18 @@ func writeJSONResponse(w http.ResponseWriter, r *http.Request, code int, payload response, err := json.Marshal(payload) if err != nil { // Headers already sent, can't change status code - logger.With(r.Context(), - logger.HTTPPath(r.URL.Path), + slog.ErrorContext(r.Context(), + "Failed to marshal JSON response payload", logger.HTTPPath(r.URL.Path), logger.HTTPMethod(r.Method), - logger.HTTPStatusCode(code), - ).WithError(err).Error("Failed to marshal JSON response payload") + logger.HTTPStatusCode(code), "error", err) return } if _, err := w.Write(response); err != nil { // Writing failed, nothing we can do at this point - logger.With(r.Context(), - logger.HTTPPath(r.URL.Path), + slog.ErrorContext(r.Context(), + "Failed to write JSON response body", logger.HTTPPath(r.URL.Path), logger.HTTPMethod(r.Method), - logger.HTTPStatusCode(code), - ).WithError(err).Error("Failed to write JSON response body") + logger.HTTPStatusCode(code), "error", err) return } } @@ -85,15 +84,15 @@ func handleError(r *http.Request, w http.ResponseWriter, err *errors.ServiceErro // Log with RFC 9457 code format if err.HTTPCode >= 400 && err.HTTPCode <= 499 { - logger.With(r.Context(), - "code", err.RFC9457Code, + slog.InfoContext(r.Context(), + "Client error response", "code", err.RFC9457Code, "http_code", err.HTTPCode, - "reason", err.Reason).Info("Client error response") + "reason", err.Reason) } else { - logger.With(r.Context(), - "code", err.RFC9457Code, + slog.ErrorContext(r.Context(), + "Server error response", "code", err.RFC9457Code, "http_code", err.HTTPCode, - "reason", err.Reason).Error("Server error response") + "reason", err.Reason) } response.WriteProblemDetailsResponse(w, r, err.HTTPCode, err.AsProblemDetails(instance, traceID)) diff --git a/pkg/handlers/metadata.go b/pkg/handlers/metadata.go index e41b0bb2..50474a23 100755 --- a/pkg/handlers/metadata.go +++ b/pkg/handlers/metadata.go @@ -18,6 +18,7 @@ package handlers import ( "encoding/json" + "log/slog" "net/http" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" @@ -45,10 +46,9 @@ func (h MetadataHandler) Get(w http.ResponseWriter, r *http.Request) { } data, err := json.Marshal(body) if err != nil { - logger.With(r.Context(), - logger.HTTPPath(r.URL.Path), - logger.HTTPMethod(r.Method), - ).WithError(err).Error("Failed to marshal metadata response") + slog.ErrorContext(r.Context(), + "Failed to marshal metadata response", logger.HTTPPath(r.URL.Path), + logger.HTTPMethod(r.Method), "error", err) api.SendPanic(w, r) return } @@ -56,10 +56,9 @@ func (h MetadataHandler) Get(w http.ResponseWriter, r *http.Request) { // Send the response: _, err = w.Write(data) if err != nil { - logger.With(r.Context(), - logger.HTTPPath(r.URL.Path), - logger.HTTPMethod(r.Method), - ).WithError(err).Error("Failed to send metadata response body") + slog.ErrorContext(r.Context(), + "Failed to send metadata response body", logger.HTTPPath(r.URL.Path), + logger.HTTPMethod(r.Method), "error", err) return } } diff --git a/pkg/handlers/openapi.go b/pkg/handlers/openapi.go index ed037832..f0c5ea34 100755 --- a/pkg/handlers/openapi.go +++ b/pkg/handlers/openapi.go @@ -4,6 +4,7 @@ import ( "context" "embed" "io/fs" + "log/slog" "net/http" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" @@ -38,7 +39,7 @@ func NewOpenAPIHandler() (*OpenAPIHandler, error) { err, ) } - logger.Info(ctx, "Loaded fully resolved OpenAPI specification from embedded pkg/api/openapi/api/openapi.yaml") + slog.InfoContext(ctx, "Loaded fully resolved OpenAPI specification from embedded pkg/api/openapi/api/openapi.yaml") // Load the OpenAPI UI HTML content uiContent, err := fs.ReadFile(openapiui, "openapi-ui.html") @@ -48,7 +49,7 @@ func NewOpenAPIHandler() (*OpenAPIHandler, error) { err, ) } - logger.Info(ctx, "Loaded OpenAPI UI HTML from embedded file") + slog.InfoContext(ctx, "Loaded OpenAPI UI HTML from embedded file") return &OpenAPIHandler{ openAPIDefinitions: data, @@ -61,11 +62,10 @@ func (h *OpenAPIHandler) GetOpenAPI(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) if _, err := w.Write(h.openAPIDefinitions); err != nil { // Response already committed, can't report error - logger.With(r.Context(), - logger.HTTPPath(r.URL.Path), + slog.ErrorContext(r.Context(), + "Failed to write OpenAPI specification response", logger.HTTPPath(r.URL.Path), logger.HTTPMethod(r.Method), - logger.HTTPStatusCode(http.StatusOK), - ).WithError(err).Error("Failed to write OpenAPI specification response") + logger.HTTPStatusCode(http.StatusOK), "error", err) return } } @@ -75,11 +75,10 @@ func (h *OpenAPIHandler) GetOpenAPIUI(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) if _, err := w.Write(h.uiContent); err != nil { // Response already committed, can't report error - logger.With(r.Context(), - logger.HTTPPath(r.URL.Path), + slog.ErrorContext(r.Context(), + "Failed to write OpenAPI UI response", logger.HTTPPath(r.URL.Path), logger.HTTPMethod(r.Method), - logger.HTTPStatusCode(http.StatusOK), - ).WithError(err).Error("Failed to write OpenAPI UI response") + logger.HTTPStatusCode(http.StatusOK), "error", err) return } } diff --git a/pkg/handlers/resource_status_handler.go b/pkg/handlers/resource_status_handler.go index 173d6336..e0ce5482 100644 --- a/pkg/handlers/resource_status_handler.go +++ b/pkg/handlers/resource_status_handler.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "log/slog" "net/http" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" @@ -72,6 +73,7 @@ func (h *ResourceStatusHandler) Create(w http.ResponseWriter, r *http.Request) { handleError(r, w, svcErr) return } + r = r.WithContext(logger.WithAdapter(r.Context(), req.Adapter)) id := r.PathValue("id") if svcErr := h.verifyResource(r, id); svcErr != nil { @@ -125,7 +127,7 @@ func (h *ResourceStatusHandler) listStatuses( for _, as := range adapterStatuses { presented, presErr := presenters.PresentAdapterStatus(as) if presErr != nil { - logger.WithError(ctx, presErr).Error("Failed to present adapter status") + slog.ErrorContext(ctx, "Failed to present adapter status", "error", presErr) return nil, errors.GeneralError("Failed to present adapter status") } items = append(items, presented) @@ -146,7 +148,7 @@ func (h *ResourceStatusHandler) processStatus( ) (interface{}, *errors.ServiceError) { newStatus, convErr := presenters.ConvertAdapterStatus(h.descriptor.Kind, resourceID, req) if convErr != nil { - logger.WithError(ctx, convErr).Error("Failed to convert adapter status") + slog.ErrorContext(ctx, "Failed to convert adapter status", "error", convErr) return nil, errors.GeneralError("Failed to convert adapter status") } @@ -163,7 +165,7 @@ func (h *ResourceStatusHandler) processStatus( status, presErr := presenters.PresentAdapterStatus(adapterStatus) if presErr != nil { - logger.WithError(ctx, presErr).Error("Failed to present adapter status") + slog.ErrorContext(ctx, "Failed to present adapter status", "error", presErr) return nil, errors.GeneralError("Failed to present adapter status") } return &status, nil diff --git a/pkg/handlers/resource_status_handler_test.go b/pkg/handlers/resource_status_handler_test.go index c60096b7..2c32ac40 100644 --- a/pkg/handlers/resource_status_handler_test.go +++ b/pkg/handlers/resource_status_handler_test.go @@ -1,7 +1,10 @@ package handlers import ( + "bytes" + "context" "encoding/json" + "log/slog" "net/http" "net/http/httptest" "strings" @@ -9,17 +12,91 @@ import ( "time" . "github.com/onsi/gomega" + hfl "github.com/openshift-hyperfleet/hyperfleet-logger" "go.uber.org/mock/gomock" "gorm.io/datatypes" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" ) const testChannelID = "ch-1" +func TestStatusHandlers_AdapterContextReachesServiceAndErrorResponse(t *testing.T) { + for _, route := range []string{"entity", "root"} { + t.Run(route, func(t *testing.T) { + var output bytes.Buffer + previous := slog.Default() + slog.SetDefault(logger.NewLogger("test", logger.HandlerConfig{ + Level: slog.LevelInfo, Format: hfl.FormatJSON, Output: &output, + })) + t.Cleanup(func() { slog.SetDefault(previous) }) + ctrl := gomock.NewController(t) + resourceSvc := services.NewMockResourceService(ctrl) + adapterSvc := services.NewMockAdapterStatusService(ctrl) + resource := &api.Resource{Kind: "Channel"} + resource.ID = testChannelID + resourceSvc.EXPECT().ProcessAdapterStatus( + gomock.Any(), resource.Kind, resource.ID, gomock.Any(), + ).DoAndReturn(func( + ctx context.Context, _, _ string, _ *api.AdapterStatus, + ) (*api.AdapterStatus, *errors.ServiceError) { + if adapter, _ := hfl.Get(ctx, logger.AdapterKey); adapter != "adapter1" { + t.Errorf("service adapter = %q, want adapter1", adapter) + } + if requestID, _ := logger.GetRequestID(ctx); requestID != "request-1" { + t.Errorf("service request ID = %q, want request-1", requestID) + } + return nil, errors.GeneralError("test failure") + }) + body := openapi.AdapterStatusCreateRequest{ + Adapter: "adapter1", ObservedGeneration: 1, ObservedTime: time.Now().UTC(), + Conditions: []openapi.ConditionRequest{ + {Type: "Available", Status: openapi.AdapterConditionStatusTrue}, + {Type: "Applied", Status: openapi.AdapterConditionStatusTrue}, + {Type: "Health", Status: openapi.AdapterConditionStatusTrue}, + }, + } + bodyJSON, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + parent := hfl.Set(t.Context(), logger.ReqIDKey, "request-1") + parent = hfl.WithTraceID(parent, "trace-1") + req := httptest.NewRequest(http.MethodPut, "/statuses", bytes.NewReader(bodyJSON)).WithContext(parent) + req.SetPathValue("id", testChannelID) + res := httptest.NewRecorder() + if route == "entity" { + resourceSvc.EXPECT().Get(gomock.Any(), resource.Kind, resource.ID).Return(resource, nil) + NewResourceStatusHandler(channelDescriptor, resourceSvc, adapterSvc).Create(res, req) + } else { + resourceSvc.EXPECT().GetByID(gomock.Any(), resource.ID).Return(resource, nil) + NewRootResourceHandler(resourceSvc, adapterSvc, nil).CreateStatus(res, req) + } + if res.Code != http.StatusInternalServerError { + t.Fatalf("HTTP status = %d, want 500", res.Code) + } + var record map[string]any + if err := json.Unmarshal(output.Bytes(), &record); err != nil { + t.Fatal(err) + } + for key, want := range map[string]string{ + "adapter": "adapter1", "request_id": "request-1", "trace_id": "trace-1", + } { + if record[key] != want { + t.Errorf("error log %s = %v, want %q", key, record[key], want) + } + } + if _, ok := hfl.Get(parent, logger.AdapterKey); ok { + t.Error("handler must not modify the caller's context") + } + }) + } +} + func newTestResourceStatusHandler( ctrl *gomock.Controller, ) (*ResourceStatusHandler, *services.MockResourceService, *services.MockAdapterStatusService) { diff --git a/pkg/handlers/root_resource_handler.go b/pkg/handlers/root_resource_handler.go index 8401ea0d..e361b778 100644 --- a/pkg/handlers/root_resource_handler.go +++ b/pkg/handlers/root_resource_handler.go @@ -2,6 +2,7 @@ package handlers import ( "fmt" + "log/slog" "net/http" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" @@ -247,7 +248,7 @@ func (h *RootResourceHandler) ListStatuses(w http.ResponseWriter, r *http.Reques for _, as := range statuses { presented, presErr := presenters.PresentAdapterStatus(as) if presErr != nil { - logger.WithError(ctx, presErr).Error("Failed to present adapter status") + slog.ErrorContext(ctx, "Failed to present adapter status", "error", presErr) handleError(r, w, errors.GeneralError("Failed to present adapter status")) return } @@ -277,6 +278,7 @@ func (h *RootResourceHandler) CreateStatus(w http.ResponseWriter, r *http.Reques handleError(r, w, svcErr) return } + r = r.WithContext(logger.WithAdapter(r.Context(), req.Adapter)) ctx := r.Context() id := r.PathValue("id") @@ -288,7 +290,7 @@ func (h *RootResourceHandler) CreateStatus(w http.ResponseWriter, r *http.Reques newStatus, convErr := presenters.ConvertAdapterStatus(resource.Kind, id, &req) if convErr != nil { - logger.WithError(ctx, convErr).Error("Failed to convert adapter status") + slog.ErrorContext(ctx, "Failed to convert adapter status", "error", convErr) handleError(r, w, errors.GeneralError("Failed to convert adapter status")) return } @@ -306,7 +308,7 @@ func (h *RootResourceHandler) CreateStatus(w http.ResponseWriter, r *http.Reques status, presErr := presenters.PresentAdapterStatus(adapterStatus) if presErr != nil { - logger.WithError(ctx, presErr).Error("Failed to present adapter status") + slog.ErrorContext(ctx, "Failed to present adapter status", "error", presErr) handleError(r, w, errors.GeneralError("Failed to present adapter status")) return } diff --git a/pkg/health/handler.go b/pkg/health/handler.go index 87d13a65..55e3722b 100644 --- a/pkg/health/handler.go +++ b/pkg/health/handler.go @@ -4,11 +4,11 @@ import ( "context" "encoding/json" "errors" + "log/slog" "net/http" "time" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) // Handler provides HTTP handlers for health checks @@ -79,7 +79,7 @@ func (h *Handler) ReadinessHandler(w http.ResponseWriter, r *http.Request) { if pingCtx.Err() == context.DeadlineExceeded || errors.Is(err, context.DeadlineExceeded) { reason = "database ping timeout" } - logger.WithError(r.Context(), err).Warn("Readiness check: database ping failed") + slog.WarnContext(r.Context(), "Readiness check: database ping failed", "error", err) w.WriteHeader(http.StatusServiceUnavailable) json.NewEncoder(w).Encode(map[string]string{ //nolint:errcheck // best-effort response after header is written "status": "not_ready", diff --git a/pkg/logger/context.go b/pkg/logger/context.go index a9ee3244..3f1f206e 100644 --- a/pkg/logger/context.go +++ b/pkg/logger/context.go @@ -5,58 +5,32 @@ import ( "fmt" "github.com/google/uuid" + hfl "github.com/openshift-hyperfleet/hyperfleet-logger" ) -// contextKey is an unexported type for keys defined in this package. -// This prevents collisions with keys defined in other packages. -type contextKey string +// Context keys for API-specific correlation fields. Trace, span, resource type, +// and resource ID keys are owned by the shared logger package. +var ReqIDKey = hfl.NewKey[string](FieldRequestID) -// Context keys for storing correlation fields -const ( - ReqIDKey contextKey = "request_id" - TraceIDCtxKey contextKey = "trace_id" - SpanIDCtxKey contextKey = "span_id" - ClusterIDCtxKey contextKey = "cluster_id" - ResourceTypeCtxKey contextKey = "resource_type" - ResourceIDCtxKey contextKey = "resource_id" -) +// AdapterKey identifies the adapter whose report is being processed. +var AdapterKey = hfl.NewKey[string](FieldAdapter) + +// WithAdapter derives a context carrying adapter identity without losing request correlation. +func WithAdapter(ctx context.Context, adapter string) context.Context { + return hfl.Set(ctx, AdapterKey, adapter) +} // HTTP header names const ( ReqIDHeader = "X-Request-ID" ) -// WithTraceID adds trace ID to context -func WithTraceID(ctx context.Context, traceID string) context.Context { - return context.WithValue(ctx, TraceIDCtxKey, traceID) -} - -// WithSpanID adds span ID to context -func WithSpanID(ctx context.Context, spanID string) context.Context { - return context.WithValue(ctx, SpanIDCtxKey, spanID) -} - -// WithClusterID adds cluster ID to context -func WithClusterID(ctx context.Context, clusterID string) context.Context { - return context.WithValue(ctx, ClusterIDCtxKey, clusterID) -} - -// WithResourceType adds resource type to context -func WithResourceType(ctx context.Context, resourceType string) context.Context { - return context.WithValue(ctx, ResourceTypeCtxKey, resourceType) -} - -// WithResourceID adds resource ID to context -func WithResourceID(ctx context.Context, resourceID string) context.Context { - return context.WithValue(ctx, ResourceIDCtxKey, resourceID) -} - // WithRequestID adds request ID to context // If request ID already exists in context, it returns the context unchanged // Otherwise, it generates a new UUID v7 and adds it to the context // Returns an error if UUID generation fails (extremely unlikely in practice) func WithRequestID(ctx context.Context) (context.Context, error) { - if ctx.Value(ReqIDKey) != nil { + if _, ok := hfl.Get(ctx, ReqIDKey); ok { return ctx, nil } @@ -65,60 +39,20 @@ func WithRequestID(ctx context.Context) (context.Context, error) { return ctx, fmt.Errorf("failed to generate request ID: %w", err) } - return context.WithValue(ctx, ReqIDKey, reqID.String()), nil + return hfl.Set(ctx, ReqIDKey, reqID.String()), nil } // GetRequestID retrieves request ID from context func GetRequestID(ctx context.Context) (string, bool) { - reqID, ok := ctx.Value(ReqIDKey).(string) - return reqID, ok + return hfl.Get(ctx, ReqIDKey) } -// GetTraceID retrieves trace ID from context -func GetTraceID(ctx context.Context) (string, bool) { - traceID, ok := ctx.Value(TraceIDCtxKey).(string) - return traceID, ok -} - -// GetSpanID retrieves span ID from context -func GetSpanID(ctx context.Context) (string, bool) { - spanID, ok := ctx.Value(SpanIDCtxKey).(string) - return spanID, ok -} - -// GetClusterID retrieves cluster ID from context -func GetClusterID(ctx context.Context) (string, bool) { - clusterID, ok := ctx.Value(ClusterIDCtxKey).(string) - return clusterID, ok -} - -// GetResourceType retrieves resource type from context -func GetResourceType(ctx context.Context) (string, bool) { - resourceType, ok := ctx.Value(ResourceTypeCtxKey).(string) - return resourceType, ok -} - -// GetResourceID retrieves resource ID from context -func GetResourceID(ctx context.Context) (string, bool) { - resourceID, ok := ctx.Value(ResourceIDCtxKey).(string) - return resourceID, ok -} - -// ContextField defines metadata for a string-type context log field -type ContextField struct { - Getter func(context.Context) (string, bool) - Key contextKey - Name string -} - -// ContextFieldsRegistry defines all string-type context fields for logging -// This is the single source of truth for string field management -// Fields are ordered as per HyperFleet Logging Specification (docs/logging.md:384) -var ContextFieldsRegistry = []ContextField{ - {GetRequestID, ReqIDKey, "request_id"}, - {GetTraceID, TraceIDCtxKey, "trace_id"}, - {GetSpanID, SpanIDCtxKey, "span_id"}, - {GetClusterID, ClusterIDCtxKey, "cluster_id"}, - {GetResourceType, ResourceTypeCtxKey, "resource_type"}, - {GetResourceID, ResourceIDCtxKey, "resource_id"}, +// ContextFields returns API-specific fields for the shared handler. Standard +// correlation fields (trace_id, span_id, resource_type, resource_id) are +// registered by hyperfleet-logger itself. +func ContextFields() []hfl.ContextField { + return []hfl.ContextField{ + hfl.StringField(ReqIDKey), + hfl.StringField(AdapterKey), + } } diff --git a/pkg/logger/fields.go b/pkg/logger/fields.go index ea75dec3..88e30f1b 100644 --- a/pkg/logger/fields.go +++ b/pkg/logger/fields.go @@ -4,32 +4,23 @@ package logger // These constants provide type safety and prevent typos when adding temporary fields to logs. // // Usage: -// logger.With(ctx, logger.FieldBindAddress, addr).Info("Server starting") +// slog.InfoContext(ctx, "Server starting", logger.FieldBindAddress, addr) // -// For high-frequency fields (>10 occurrences), use helper functions instead (e.g., WithError). +// For high-frequency fields (>10 occurrences), use the shared context helpers instead. // Server/Config related fields const ( FieldBindAddress = "bind_address" - FieldEnvironment = "environment" FieldLogLevel = "level" FieldLogFormat = "format" FieldLogOutput = "output" ) -// Resource related fields -const ( - FieldNodePoolID = "nodepool_id" - // Note: cluster_id, resource_type, resource_id are context fields (see context.go) -) - // Database related fields const ( - FieldMigrationID = "migration_id" // FieldConnectionString - WARNING: Always sanitize connection strings before logging // to prevent exposing passwords. Never log raw connection strings. FieldConnectionString = "connection_string" - FieldTable = "table" FieldChannel = "channel" FieldLockID = "lock_id" FieldLockType = "lock_type" @@ -39,14 +30,12 @@ const ( // OpenTelemetry related fields const ( - FieldOTelEnabled = "otel_enabled" - FieldSamplingRate = "sampling_rate" - FieldExporterEndpoint = "exporter_endpoint" - FieldHyperfleetTracingEnabled = "hyperfleet_tracing_enabled" - FieldServiceName = "service_name" - FieldProtocol = "protocol" - FieldSampler = "sampler" - FieldServiceVersion = "service_version" + FieldOTelEnabled = "otel_enabled" + FieldSamplingRate = "sampling_rate" + FieldServiceName = "service_name" + FieldProtocol = "protocol" + FieldSampler = "sampler" + FieldServiceVersion = "service_version" ) // Schema related fields @@ -56,16 +45,10 @@ const ( // Generic fields const ( + FieldRequestID = "request_id" FieldAdapter = "adapter" FieldErrorCode = "error_code" - FieldFlag = "flag" FieldData = "data" ) -// Endpoint related fields (used in handlers) -const ( - FieldEndpoint = "endpoint" -) - // Note: HTTP-related field constants are defined in http.go -// Note: For error field, use WithError(ctx, err) helper function instead of FieldError constant diff --git a/pkg/logger/gorm_logger.go b/pkg/logger/gorm_logger.go index 407995f4..5577d34a 100644 --- a/pkg/logger/gorm_logger.go +++ b/pkg/logger/gorm_logger.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "time" gormlogger "gorm.io/gorm/logger" @@ -30,19 +31,19 @@ func (l *GormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface { func (l *GormLogger) Info(ctx context.Context, msg string, data ...interface{}) { if l.logLevel >= gormlogger.Info { - With(ctx, "gorm_info", formatMessage(msg, data)).Info("GORM info") + slog.InfoContext(ctx, "GORM info", "gorm_info", formatMessage(msg, data)) } } func (l *GormLogger) Warn(ctx context.Context, msg string, data ...interface{}) { if l.logLevel >= gormlogger.Warn { - With(ctx, "gorm_warn", formatMessage(msg, data)).Warn("GORM warning") + slog.WarnContext(ctx, "GORM warning", "gorm_warn", formatMessage(msg, data)) } } func (l *GormLogger) Error(ctx context.Context, msg string, data ...interface{}) { if l.logLevel >= gormlogger.Error { - With(ctx, "gorm_error", formatMessage(msg, data)).Error("GORM error") + slog.ErrorContext(ctx, "GORM error", "gorm_error", formatMessage(msg, data)) } } @@ -61,27 +62,27 @@ func (l *GormLogger) Trace( switch { case err != nil && l.logLevel >= gormlogger.Error && !errors.Is(err, gormlogger.ErrRecordNotFound): - With(ctx, - "error", err.Error(), + slog.ErrorContext(ctx, "GORM query error", + "error", err, "duration_ms", float64(elapsed.Nanoseconds())/1e6, "rows", rows, "sql", sql, - ).Error("GORM query error") + ) case elapsed > l.slowThreshold && l.slowThreshold != 0 && l.logLevel >= gormlogger.Warn: - With(ctx, + slog.WarnContext(ctx, "GORM slow query", "duration_ms", float64(elapsed.Nanoseconds())/1e6, "threshold_ms", float64(l.slowThreshold.Nanoseconds())/1e6, "rows", rows, "sql", sql, - ).Warn("GORM slow query") + ) case l.logLevel >= gormlogger.Info: - With(ctx, + slog.InfoContext(ctx, "GORM query", "duration_ms", float64(elapsed.Nanoseconds())/1e6, "rows", rows, "sql", sql, - ).Info("GORM query") + ) } } diff --git a/pkg/logger/handler.go b/pkg/logger/handler.go new file mode 100644 index 00000000..c087047d --- /dev/null +++ b/pkg/logger/handler.go @@ -0,0 +1,48 @@ +package logger + +import ( + "context" + "io" + "log/slog" + + hfl "github.com/openshift-hyperfleet/hyperfleet-logger" +) + +// Component is the logging component identity for HyperFleet API. +const Component = "api" + +// HandlerConfig configures the API-owned extensions to the shared handler. +// An empty Hostname uses hyperfleet-logger's OS hostname discovery. +type HandlerConfig struct { + Output io.Writer + Hostname string + Level slog.Level + Format hfl.Format +} + +// NewHandler builds a shared HyperFleet logging handler with API correlation +// fields and the API stack-trace policy. +func NewHandler(version string, cfg HandlerConfig) slog.Handler { + opts := []hfl.Option{ + hfl.WithLevel(cfg.Level), + hfl.WithFormat(cfg.Format), + hfl.WithOutput(cfg.Output), + hfl.WithContextFields(ContextFields()...), + hfl.WithSanitize(), + } + // Preserve the previous handlers: JSON captures every ERROR stack; + // text does not automatically capture stacks. + if cfg.Format == hfl.FormatJSON { + opts = append(opts, hfl.WithStackTrace(func(context.Context, slog.Record) bool { return true })) + } + if cfg.Hostname != "" { + opts = append(opts, hfl.WithHostname(cfg.Hostname)) + } + return hfl.NewHandler(Component, version, opts...) +} + +// NewLogger returns an isolated logger. Callers that require process-wide +// logging should install it with slog.SetDefault in their composition root. +func NewLogger(version string, cfg HandlerConfig) *slog.Logger { + return slog.New(NewHandler(version, cfg)) +} diff --git a/pkg/logger/handler_test.go b/pkg/logger/handler_test.go new file mode 100644 index 00000000..497964b1 --- /dev/null +++ b/pkg/logger/handler_test.go @@ -0,0 +1,91 @@ +package logger + +import ( + "bytes" + "encoding/json" + "log/slog" + "strings" + "testing" + + hfl "github.com/openshift-hyperfleet/hyperfleet-logger" +) + +func TestHandlerAddsAPICorrelationFields(t *testing.T) { + var output bytes.Buffer + ctx := hfl.WithTraceID(t.Context(), "trace-1") + ctx = hfl.WithSpanID(ctx, "span-1") + ctx = hfl.WithResourceType(ctx, "Cluster") + ctx = hfl.WithResourceID(ctx, "cluster-1") + ctx, err := WithRequestID(ctx) + if err != nil { + t.Fatal(err) + } + + NewLogger("test-version", HandlerConfig{ + Level: slog.LevelInfo, Format: hfl.FormatJSON, Output: &output, Hostname: "test-host", + }).InfoContext(ctx, "cluster updated") + + var record map[string]any + if err := json.Unmarshal(output.Bytes(), &record); err != nil { + t.Fatal(err) + } + for key, want := range map[string]string{ + "component": Component, + "version": "test-version", + "hostname": "test-host", + "trace_id": "trace-1", + "span_id": "span-1", + "resource_type": "Cluster", + "resource_id": "cluster-1", + } { + if got := record[key]; got != want { + t.Errorf("%s = %v, want %q", key, got, want) + } + } + if _, ok := record["request_id"].(string); !ok { + t.Errorf("request_id = %T, want string", record["request_id"]) + } +} + +func TestHandlerSanitizesText(t *testing.T) { + var output bytes.Buffer + log := NewLogger("test", HandlerConfig{ + Level: slog.LevelInfo, Format: hfl.FormatText, Output: &output, Hostname: "test-host", + }) + log.ErrorContext(t.Context(), "expected\nerror") + if strings.Contains(output.String(), "expected\nerror") { + t.Fatal("text output must sanitize embedded control characters") + } +} + +func TestHandlerPreservesStackCaptureByFormat(t *testing.T) { + for _, format := range []struct { + name string + value hfl.Format + }{ + {name: "json", value: hfl.FormatJSON}, + {name: "text", value: hfl.FormatText}, + } { + for _, configured := range []slog.Level{slog.LevelDebug, slog.LevelInfo, slog.LevelError} { + for _, emitted := range []slog.Level{slog.LevelInfo, slog.LevelWarn, slog.LevelError, slog.LevelError + 4} { + t.Run(format.name+"/"+configured.String()+"/"+emitted.String(), func(t *testing.T) { + var output bytes.Buffer + NewLogger("test", HandlerConfig{ + Level: configured, Format: format.value, Output: &output, + }).Log(t.Context(), emitted, "test record") + if emitted < configured { + if output.Len() != 0 { + t.Fatal("disabled records must not be emitted") + } + return + } + hasStack := bytes.Contains(output.Bytes(), []byte("stack_trace")) + wantStack := format.value == hfl.FormatJSON && emitted >= slog.LevelError + if hasStack != wantStack { + t.Errorf("stack present = %v, want %v: %s", hasStack, wantStack, output.String()) + } + }) + } + } + } +} diff --git a/pkg/logger/http.go b/pkg/logger/http.go index 06725949..da177bc8 100644 --- a/pkg/logger/http.go +++ b/pkg/logger/http.go @@ -7,34 +7,34 @@ import ( // HTTP field name constants const ( - FieldHTTPMethod = "method" - FieldHTTPPath = "path" - FieldHTTPStatusCode = "status_code" - FieldHTTPDuration = "duration_ms" - FieldHTTPUserAgent = "user_agent" + fieldHTTPMethod = "method" + fieldHTTPPath = "path" + fieldHTTPStatusCode = "status_code" + fieldHTTPDuration = "duration_ms" + fieldHTTPUserAgent = "user_agent" ) // HTTPMethod returns a slog attribute for HTTP method func HTTPMethod(method string) slog.Attr { - return slog.String(FieldHTTPMethod, method) + return slog.String(fieldHTTPMethod, method) } // HTTPPath returns a slog attribute for HTTP path func HTTPPath(path string) slog.Attr { - return slog.String(FieldHTTPPath, path) + return slog.String(fieldHTTPPath, path) } // HTTPStatusCode returns a slog attribute for HTTP status code func HTTPStatusCode(code int) slog.Attr { - return slog.Int(FieldHTTPStatusCode, code) + return slog.Int(fieldHTTPStatusCode, code) } // HTTPDuration returns a slog attribute for HTTP request duration in milliseconds func HTTPDuration(d time.Duration) slog.Attr { - return slog.Int64(FieldHTTPDuration, d.Milliseconds()) + return slog.Int64(fieldHTTPDuration, d.Milliseconds()) } // HTTPUserAgent returns a slog attribute for HTTP user agent func HTTPUserAgent(ua string) slog.Attr { - return slog.String(FieldHTTPUserAgent, ua) + return slog.String(fieldHTTPUserAgent, ua) } diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go deleted file mode 100755 index 61164cde..00000000 --- a/pkg/logger/logger.go +++ /dev/null @@ -1,320 +0,0 @@ -package logger - -import ( - "context" - "fmt" - "io" - "log/slog" - "os" - "runtime" - "strings" - "sync/atomic" -) - -// LogFormat enumeration for output format -type LogFormat int - -const ( - // FormatText outputs logs in human-readable text format - FormatText LogFormat = iota - // FormatJSON outputs logs in JSON format for structured logging - FormatJSON -) - -// LogConfig holds the configuration for the logger -type LogConfig struct { - Output io.Writer - Component string - Version string - Hostname string - Level slog.Level - Format LogFormat -} - -// HyperFleetHandler implements slog.Handler interface -// Adds HyperFleet-specific fields: component, version, hostname, trace_id, span_id, etc. -type HyperFleetHandler struct { - handler slog.Handler - component string - version string - hostname string -} - -// NewHyperFleetHandler creates a HyperFleet logger handler -// Returns slog.Handler interface to support both HyperFleetHandler (JSON) and HyperFleetTextHandler (Text) -func NewHyperFleetHandler(cfg *LogConfig) slog.Handler { - if cfg.Format == FormatText { - return NewHyperFleetTextHandler(cfg.Output, cfg.Component, cfg.Version, cfg.Hostname, cfg.Level) - } - - var baseHandler slog.Handler - opts := &slog.HandlerOptions{ - Level: cfg.Level, - AddSource: true, - ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { - if a.Key == slog.TimeKey { - return slog.Attr{Key: "timestamp", Value: a.Value} - } - if a.Key == slog.LevelKey { - if level, ok := a.Value.Any().(slog.Level); ok { - return slog.Attr{Key: "level", Value: slog.StringValue(strings.ToLower(level.String()))} - } - return slog.Attr{Key: "level", Value: slog.StringValue(strings.ToLower(fmt.Sprint(a.Value.Any())))} - } - if a.Key == slog.MessageKey { - return slog.Attr{Key: "message", Value: a.Value} - } - if a.Key == slog.SourceKey { - return a - } - return a - }, - } - - baseHandler = slog.NewJSONHandler(cfg.Output, opts) - - return &HyperFleetHandler{ - handler: baseHandler, - component: cfg.Component, - version: cfg.Version, - hostname: cfg.Hostname, - } -} - -// Handle implements slog.Handler interface -func (h *HyperFleetHandler) Handle(ctx context.Context, r slog.Record) error { - r.AddAttrs( - slog.String("component", h.component), - slog.String("version", h.version), - slog.String("hostname", h.hostname), - ) - - for _, field := range ContextFieldsRegistry { - if val, ok := field.Getter(ctx); ok { - r.AddAttrs(slog.String(field.Name, val)) - } - } - - if r.Level >= slog.LevelError { - stackTrace := captureStackTrace(4) - r.AddAttrs(slog.Any("stack_trace", stackTrace)) - } - - return h.handler.Handle(ctx, r) -} - -// Enabled implements slog.Handler interface -func (h *HyperFleetHandler) Enabled(ctx context.Context, level slog.Level) bool { - return h.handler.Enabled(ctx, level) -} - -// WithAttrs implements slog.Handler interface -func (h *HyperFleetHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - return &HyperFleetHandler{ - handler: h.handler.WithAttrs(attrs), - component: h.component, - version: h.version, - hostname: h.hostname, - } -} - -// WithGroup implements slog.Handler interface -func (h *HyperFleetHandler) WithGroup(name string) slog.Handler { - return &HyperFleetHandler{ - handler: h.handler.WithGroup(name), - component: h.component, - version: h.version, - hostname: h.hostname, - } -} - -func captureStackTrace(skip int) []string { - const maxFrames = 15 - pcs := make([]uintptr, maxFrames) - n := runtime.Callers(skip, pcs) - - frames := runtime.CallersFrames(pcs[:n]) - var stackTrace []string - for { - frame, more := frames.Next() - if !strings.Contains(frame.Function, "runtime.") && - !strings.Contains(frame.Function, "testing.") { - stackTrace = append(stackTrace, - fmt.Sprintf("%s:%d %s", frame.File, frame.Line, frame.Function)) - } - if !more { - break - } - } - return stackTrace -} - -var globalLogger atomic.Value // stores *slog.Logger - -// InitGlobalLogger initializes the global logger with the given configuration. -// This function is idempotent (safe to call multiple times). -// -// Concurrency note: This function uses atomic.Value without sync.Once intentionally. -// Current call sites are serialized (single-threaded main() and sync.Once in tests). -// If concurrent initialization occurs, the last Store() wins, which is acceptable. -// For stricter guarantees in future use cases, callers should wrap this with sync.Once -// or equivalent synchronization. -func InitGlobalLogger(cfg *LogConfig) { - if v := globalLogger.Load(); v != nil { - if logger, ok := v.(*slog.Logger); ok && logger != nil { - return - } - } - - handler := NewHyperFleetHandler(cfg) - logger := slog.New(handler) - globalLogger.Store(logger) - slog.SetDefault(logger) -} - -// ReconfigureGlobalLogger reconfigures the global logger with new configuration -// Unlike InitGlobalLogger, this can be called multiple times to update configuration -// This is useful when environment configuration is loaded after initial logger setup -func ReconfigureGlobalLogger(cfg *LogConfig) { - handler := NewHyperFleetHandler(cfg) - newLogger := slog.New(handler) - globalLogger.Store(newLogger) - slog.SetDefault(newLogger) -} - -// resetForTesting resets the global logger for testing purposes -// This function should ONLY be used in tests -func resetForTesting() { - globalLogger.Store((*slog.Logger)(nil)) -} - -// GetLogger returns the global logger instance -func GetLogger() *slog.Logger { - if v := globalLogger.Load(); v != nil { - if logger, ok := v.(*slog.Logger); ok && logger != nil { - return logger - } - } - return slog.Default() -} - -// ParseLogLevel converts string to slog.Level -func ParseLogLevel(level string) (slog.Level, error) { - switch strings.ToLower(strings.TrimSpace(level)) { - case "debug": - return slog.LevelDebug, nil - case "info": - return slog.LevelInfo, nil - case "warn", "warning": - return slog.LevelWarn, nil - case "error": - return slog.LevelError, nil - default: - return slog.LevelInfo, fmt.Errorf("unknown log level: %s (valid: debug, info, warn, error)", level) - } -} - -// ParseLogFormat converts string to LogFormat -func ParseLogFormat(format string) (LogFormat, error) { - switch strings.ToLower(strings.TrimSpace(format)) { - case "text": - return FormatText, nil - case "json": - return FormatJSON, nil - default: - return FormatText, fmt.Errorf("unknown log format: %s (valid: text, json)", format) - } -} - -// ParseLogOutput converts string to io.Writer -func ParseLogOutput(output string) (io.Writer, error) { - switch strings.ToLower(strings.TrimSpace(output)) { - case "stdout", "": - return os.Stdout, nil - case "stderr": - return os.Stderr, nil - default: - return nil, fmt.Errorf("unknown log output: %s (valid: stdout, stderr)", output) - } -} - -// Debug logs at Debug level with context fields only. -// For temporary fields, use With(). -func Debug(ctx context.Context, msg string) { - GetLogger().DebugContext(ctx, msg) -} - -// Info logs at Info level with context fields only. -// For temporary fields, use With(). -func Info(ctx context.Context, msg string) { - GetLogger().InfoContext(ctx, msg) -} - -// Warn logs at Warn level with context fields only. -// For temporary fields, use With(). -func Warn(ctx context.Context, msg string) { - GetLogger().WarnContext(ctx, msg) -} - -// Error logs at Error level with context fields only. -// For temporary fields, use With(). -func Error(ctx context.Context, msg string) { - GetLogger().ErrorContext(ctx, msg) -} - -// ContextLogger wraps a context with additional temporary key-value pairs for logging. -type ContextLogger struct { - ctx context.Context - logger *slog.Logger -} - -// With creates a new ContextLogger with temporary key-value pairs. -func With(ctx context.Context, args ...any) *ContextLogger { - return &ContextLogger{ - ctx: ctx, - logger: GetLogger().With(args...), - } -} - -// WithError is a convenience function for adding error field to logs. -func WithError(ctx context.Context, err error) *ContextLogger { - if err == nil { - return With(ctx) - } - return With(ctx, "error", err.Error()) -} - -// WithError adds an error field to the logger and can be chained. -func (l *ContextLogger) WithError(err error) *ContextLogger { - if err == nil { - return l - } - return &ContextLogger{ - ctx: l.ctx, - logger: l.logger.With("error", err.Error()), - } -} - -// With adds additional temporary fields to the logger and can be chained. -func (l *ContextLogger) With(args ...any) *ContextLogger { - return &ContextLogger{ - ctx: l.ctx, - logger: l.logger.With(args...), - } -} - -func (l *ContextLogger) Debug(msg string) { - l.logger.DebugContext(l.ctx, msg) -} - -func (l *ContextLogger) Info(msg string) { - l.logger.InfoContext(l.ctx, msg) -} - -func (l *ContextLogger) Warn(msg string) { - l.logger.WarnContext(l.ctx, msg) -} - -func (l *ContextLogger) Error(msg string) { - l.logger.ErrorContext(l.ctx, msg) -} diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go deleted file mode 100644 index a44b9465..00000000 --- a/pkg/logger/logger_test.go +++ /dev/null @@ -1,841 +0,0 @@ -package logger - -import ( - "bytes" - "context" - "encoding/json" - "log/slog" - "os" - "strings" - "testing" -) - -const ( - testMessage = "test message" -) - -// TestParseLogLevel tests log level parsing with various inputs -func TestParseLogLevel(t *testing.T) { - tests := []struct { - name string - input string - expected slog.Level - expectErr bool - }{ - {"debug", "debug", slog.LevelDebug, false}, - {"info", "info", slog.LevelInfo, false}, - {"warn", "warn", slog.LevelWarn, false}, - {"warning", "warning", slog.LevelWarn, false}, - {"error", "error", slog.LevelError, false}, - {"case insensitive", "DEBUG", slog.LevelDebug, false}, - {"with whitespace", " info ", slog.LevelInfo, false}, - {"invalid", "invalid", slog.LevelInfo, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - level, err := ParseLogLevel(tt.input) - if tt.expectErr { - if err == nil { - t.Error("expected error, got nil") - } - if !strings.Contains(err.Error(), "unknown log level") { - t.Errorf("unexpected error message: %v", err) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if level != tt.expected { - t.Errorf("expected %v, got %v", tt.expected, level) - } - } - }) - } -} - -// TestParseLogFormat tests log format parsing with various inputs -func TestParseLogFormat(t *testing.T) { - tests := []struct { - name string - input string - expected LogFormat - expectErr bool - }{ - {"text", "text", FormatText, false}, - {"json", "json", FormatJSON, false}, - {"case insensitive", "JSON", FormatJSON, false}, - {"invalid", "invalid", FormatText, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - format, err := ParseLogFormat(tt.input) - if tt.expectErr { - if err == nil { - t.Error("expected error, got nil") - } - if !strings.Contains(err.Error(), "unknown log format") { - t.Errorf("unexpected error message: %v", err) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if format != tt.expected { - t.Errorf("expected %v, got %v", tt.expected, format) - } - } - }) - } -} - -// TestParseLogOutput tests log output parsing with various inputs -func TestParseLogOutput(t *testing.T) { - tests := []struct { - expected *os.File - name string - input string - expectErr bool - }{ - {name: "stdout", input: "stdout", expected: os.Stdout, expectErr: false}, - {name: "stderr", input: "stderr", expected: os.Stderr, expectErr: false}, - {name: "empty defaults to stdout", input: "", expected: os.Stdout, expectErr: false}, - {name: "invalid", input: "invalid", expected: nil, expectErr: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - output, err := ParseLogOutput(tt.input) - if tt.expectErr { - if err == nil { - t.Error("expected error, got nil") - } - if !strings.Contains(err.Error(), "unknown log output") { - t.Errorf("unexpected error message: %v", err) - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if output != tt.expected { - t.Errorf("expected %v, got %v", tt.expected, output) - } - } - }) - } -} - -// TestBasicLogFormat tests basic log format output for both JSON and text formats -func TestBasicLogFormat(t *testing.T) { - tests := []struct { - name string - format LogFormat - }{ - {"JSON format", FormatJSON}, - {"Text format", FormatText}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resetForTesting() - var buf bytes.Buffer - cfg := &LogConfig{ - Level: slog.LevelInfo, - Format: tt.format, - Output: &buf, - Component: "api", - Version: "test-version", - Hostname: "test-host", - } - - InitGlobalLogger(cfg) - ctx := context.Background() - With(ctx, "key", "value").Info(testMessage) - - output := buf.String() - if output == "" { - t.Fatal("expected log output, got none") - } - - if tt.format == FormatJSON { - // Parse the last JSON line (handles multi-line output like stack traces) - output = strings.TrimSpace(buf.String()) - lines := strings.Split(output, "\n") - lastLine := lines[len(lines)-1] - - var logEntry map[string]interface{} - if err := json.Unmarshal([]byte(lastLine), &logEntry); err != nil { - t.Fatalf("failed to parse JSON log: %v", err) - } - - // Check required fields - if logEntry["message"] != testMessage { - t.Errorf("expected message %q, got %v", testMessage, logEntry["message"]) - } - if logEntry["level"] != "info" { - t.Errorf("expected level 'info', got %v", logEntry["level"]) - } - if logEntry["component"] != "api" { - t.Errorf("expected component 'api', got %v", logEntry["component"]) - } - if logEntry["version"] != "test-version" { - t.Errorf("expected version 'test-version', got %v", logEntry["version"]) - } - if logEntry["hostname"] != "test-host" { - t.Errorf("expected hostname 'test-host', got %v", logEntry["hostname"]) - } - if logEntry["key"] != "value" { - t.Errorf("expected key 'value', got %v", logEntry["key"]) - } - } else { - // Text format - check for stable invariants only - if !strings.Contains(output, testMessage) { - t.Errorf("expected output to contain %q, got: %s", testMessage, output) - } - // Check for log level (case-insensitive) - outputLower := strings.ToLower(output) - if !strings.Contains(outputLower, "info") { - t.Errorf("expected output to contain log level 'info', got: %s", output) - } - } - }) - } -} - -// TestContextFields tests context field extraction for all supported fields -func TestContextFields(t *testing.T) { - tests := []struct { - name string - setupCtx func(context.Context) context.Context - fieldName string - fieldValue string - }{ - { - name: "trace_id", - setupCtx: func(ctx context.Context) context.Context { return WithTraceID(ctx, "trace-123") }, - fieldName: "trace_id", - fieldValue: "trace-123", - }, - { - name: "span_id", - setupCtx: func(ctx context.Context) context.Context { return WithSpanID(ctx, "span-456") }, - fieldName: "span_id", - fieldValue: "span-456", - }, - { - name: "request_id", - setupCtx: func(ctx context.Context) context.Context { - return context.WithValue(ctx, ReqIDKey, "req-789") - }, - fieldName: "request_id", - fieldValue: "req-789", - }, - { - name: "cluster_id", - setupCtx: func(ctx context.Context) context.Context { return WithClusterID(ctx, "cluster-abc") }, - fieldName: "cluster_id", - fieldValue: "cluster-abc", - }, - { - name: "resource_type", - setupCtx: func(ctx context.Context) context.Context { return WithResourceType(ctx, "managed-cluster") }, - fieldName: "resource_type", - fieldValue: "managed-cluster", - }, - { - name: "resource_id", - setupCtx: func(ctx context.Context) context.Context { return WithResourceID(ctx, "resource-xyz") }, - fieldName: "resource_id", - fieldValue: "resource-xyz", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resetForTesting() - var buf bytes.Buffer - cfg := &LogConfig{ - Level: slog.LevelInfo, - Format: FormatJSON, - Output: &buf, - Component: "api", - Version: "test-version", - Hostname: "test-host", - } - InitGlobalLogger(cfg) - - ctx := tt.setupCtx(context.Background()) - Info(ctx, "test message") - - // Parse the last JSON line (handles multi-line output like stack traces) - output := strings.TrimSpace(buf.String()) - lines := strings.Split(output, "\n") - lastLine := lines[len(lines)-1] - - var logEntry map[string]interface{} - if err := json.Unmarshal([]byte(lastLine), &logEntry); err != nil { - t.Fatalf("failed to parse JSON log: %v", err) - } - - if logEntry[tt.fieldName] != tt.fieldValue { - t.Errorf("expected %s='%s', got %v", tt.fieldName, tt.fieldValue, logEntry[tt.fieldName]) - } - }) - } -} - -// TestContextFields_Multiple tests multiple context fields together -func TestContextFields_Multiple(t *testing.T) { - resetForTesting() - var buf bytes.Buffer - cfg := &LogConfig{ - Level: slog.LevelInfo, - Format: FormatJSON, - Output: &buf, - Component: "api", - Version: "test-version", - Hostname: "test-host", - } - - InitGlobalLogger(cfg) - ctx := context.Background() - ctx = WithTraceID(ctx, "trace-123") - ctx = WithSpanID(ctx, "span-456") - ctx = WithClusterID(ctx, "cluster-abc") - Info(ctx, "test message") - - // Parse the last JSON line (handles multi-line output like stack traces) - output := strings.TrimSpace(buf.String()) - lines := strings.Split(output, "\n") - lastLine := lines[len(lines)-1] - - var logEntry map[string]interface{} - if err := json.Unmarshal([]byte(lastLine), &logEntry); err != nil { - t.Fatalf("failed to parse JSON log: %v", err) - } - - if logEntry["trace_id"] != "trace-123" { - t.Errorf("expected trace_id 'trace-123', got %v", logEntry["trace_id"]) - } - if logEntry["span_id"] != "span-456" { - t.Errorf("expected span_id 'span-456', got %v", logEntry["span_id"]) - } - if logEntry["cluster_id"] != "cluster-abc" { - t.Errorf("expected cluster_id 'cluster-abc', got %v", logEntry["cluster_id"]) - } -} - -// TestStackTrace tests stack trace capture for different log levels -func TestStackTrace(t *testing.T) { - tests := []struct { - logFunc func(context.Context, string) - name string - level slog.Level - expectStackTrace bool - }{ - {name: "error level has stack trace", level: slog.LevelError, logFunc: Error, expectStackTrace: true}, - {name: "info level no stack trace", level: slog.LevelInfo, logFunc: Info, expectStackTrace: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resetForTesting() - var buf bytes.Buffer - cfg := &LogConfig{ - Level: tt.level, - Format: FormatJSON, - Output: &buf, - Component: "api", - Version: "test-version", - Hostname: "test-host", - } - InitGlobalLogger(cfg) - - ctx := context.Background() - tt.logFunc(ctx, "test message") - - // Parse the last JSON line (handles multi-line output like stack traces) - output := strings.TrimSpace(buf.String()) - lines := strings.Split(output, "\n") - lastLine := lines[len(lines)-1] - - var logEntry map[string]interface{} - if err := json.Unmarshal([]byte(lastLine), &logEntry); err != nil { - t.Fatalf("failed to parse JSON log: %v", err) - } - - if tt.expectStackTrace { - if logEntry["stack_trace"] == nil { - t.Error("expected stack_trace field for error level") - } - // Verify stack trace is an array - stackTrace, ok := logEntry["stack_trace"].([]interface{}) - if !ok { - t.Fatalf("expected stack_trace to be array, got %T", logEntry["stack_trace"]) - } - if len(stackTrace) == 0 { - t.Error("expected stack_trace to have at least one frame") - } - } else if logEntry["stack_trace"] != nil { - t.Error("expected no stack_trace field for non-error level") - } - }) - } -} - -// TestLogLevelFiltering tests log level filtering -func TestLogLevelFiltering(t *testing.T) { - tests := []struct { - logFunc func(context.Context, string) - name string - configLevel slog.Level - shouldLog bool - }{ - {name: "debug filtered at info level", configLevel: slog.LevelInfo, logFunc: Debug, shouldLog: false}, - {name: "info enabled at info level", configLevel: slog.LevelInfo, logFunc: Info, shouldLog: true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resetForTesting() - var buf bytes.Buffer - cfg := &LogConfig{ - Level: tt.configLevel, - Format: FormatJSON, - Output: &buf, - Component: "api", - Version: "test-version", - Hostname: "test-host", - } - InitGlobalLogger(cfg) - - ctx := context.Background() - tt.logFunc(ctx, "test message") - - if tt.shouldLog && buf.Len() == 0 { - t.Error("expected log output, got none") - } - if !tt.shouldLog && buf.Len() > 0 { - t.Errorf("expected no log output, got: %s", buf.String()) - } - }) - } -} - -// TestGetLogger_Uninitialized tests GetLogger with uninitialized global logger -func TestGetLogger_Uninitialized(t *testing.T) { - // Save and restore global logger - saved := globalLogger.Load() - defer func() { globalLogger.Store(saved) }() - - globalLogger.Store((*slog.Logger)(nil)) - logger := GetLogger() - - if logger == nil { - t.Error("expected GetLogger to return non-nil logger") - } - - // Should return default logger - if logger != slog.Default() { - t.Error("expected GetLogger to return slog.Default() when uninitialized") - } -} - -// TestConvenienceFunctions tests all convenience functions (Debug, Info, Warn, Error) -func TestConvenienceFunctions(t *testing.T) { - tests := []struct { - logFunc func(context.Context, string) - name string - expectedLvl string - level slog.Level - }{ - {name: "Debug", level: slog.LevelDebug, logFunc: Debug, expectedLvl: "debug"}, - {name: "Info", level: slog.LevelInfo, logFunc: Info, expectedLvl: "info"}, - {name: "Warn", level: slog.LevelWarn, logFunc: Warn, expectedLvl: "warn"}, - {name: "Error", level: slog.LevelError, logFunc: Error, expectedLvl: "error"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resetForTesting() - var buf bytes.Buffer - cfg := &LogConfig{ - Level: tt.level, - Format: FormatJSON, - Output: &buf, - Component: "api", - Version: "test-version", - Hostname: "test-host", - } - InitGlobalLogger(cfg) - - ctx := context.Background() - tt.logFunc(ctx, "test message") - - // Parse the last JSON line (handles multi-line output like stack traces) - output := strings.TrimSpace(buf.String()) - lines := strings.Split(output, "\n") - lastLine := lines[len(lines)-1] - - var logEntry map[string]interface{} - if err := json.Unmarshal([]byte(lastLine), &logEntry); err != nil { - t.Fatalf("failed to parse JSON log: %v", err) - } - - if logEntry["level"] != tt.expectedLvl { - t.Errorf("expected level '%s', got %v", tt.expectedLvl, logEntry["level"]) - } - if logEntry["message"] != "test message" { - t.Errorf("expected message 'test message', got %v", logEntry["message"]) - } - }) - } -} - -// TestWithError tests the WithError convenience function -func TestWithError(t *testing.T) { - tests := []struct { - err error - name string - expectedValue string - expectError bool - }{ - { - name: "non-nil error", - err: bytes.ErrTooLarge, - expectError: true, - expectedValue: "bytes.Buffer: too large", - }, - { - name: "nil error", - err: nil, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - resetForTesting() - var buf bytes.Buffer - cfg := &LogConfig{ - Level: slog.LevelInfo, - Format: FormatJSON, - Output: &buf, - Component: "api", - Version: "test-version", - Hostname: "test-host", - } - InitGlobalLogger(cfg) - - ctx := context.Background() - WithError(ctx, tt.err).Info("test message") - - // Parse the last JSON line - output := strings.TrimSpace(buf.String()) - lines := strings.Split(output, "\n") - lastLine := lines[len(lines)-1] - - var logEntry map[string]interface{} - if err := json.Unmarshal([]byte(lastLine), &logEntry); err != nil { - t.Fatalf("failed to parse JSON log: %v", err) - } - - // Verify message is always present - if logEntry["message"] != "test message" { - t.Errorf("expected message 'test message', got %v", logEntry["message"]) - } - - // Check error field presence - if tt.expectError { - if logEntry["error"] == nil { - t.Error("expected error field to be present") - } - if logEntry["error"] != tt.expectedValue { - t.Errorf("expected error '%s', got %v", tt.expectedValue, logEntry["error"]) - } - } else if logEntry["error"] != nil { - t.Errorf("expected no error field for nil error, got %v", logEntry["error"]) - } - }) - } -} - -// TestWithError_MethodChaining tests that WithError returns a ContextLogger for method chaining -func TestWithError_MethodChaining(t *testing.T) { - resetForTesting() - var buf bytes.Buffer - cfg := &LogConfig{ - Level: slog.LevelInfo, - Format: FormatJSON, - Output: &buf, - Component: "api", - Version: "test-version", - Hostname: "test-host", - } - InitGlobalLogger(cfg) - - ctx := context.Background() - testErr := bytes.ErrTooLarge - - // Test method chaining works at different log levels - WithError(ctx, testErr).Info("info message") - WithError(ctx, testErr).Warn("warn message") - WithError(ctx, testErr).Error("error message") - - output := buf.String() - if !strings.Contains(output, "info message") { - t.Error("expected output to contain 'info message'") - } - if !strings.Contains(output, "warn message") { - t.Error("expected output to contain 'warn message'") - } - if !strings.Contains(output, "error message") { - t.Error("expected output to contain 'error message'") - } -} - -// TestContextLogger_With_Single tests single With call -func TestContextLogger_With_Single(t *testing.T) { - resetForTesting() - var buf bytes.Buffer - cfg := &LogConfig{ - Level: slog.LevelInfo, - Format: FormatJSON, - Output: &buf, - Component: "api", - Version: "test-version", - Hostname: "test-host", - } - InitGlobalLogger(cfg) - - ctx := context.Background() - With(ctx, "user_id", "user123").With("action", "login").Info("User action") - - // Parse the last JSON line - output := strings.TrimSpace(buf.String()) - lines := strings.Split(output, "\n") - lastLine := lines[len(lines)-1] - - var logEntry map[string]interface{} - if err := json.Unmarshal([]byte(lastLine), &logEntry); err != nil { - t.Fatalf("failed to parse JSON log: %v", err) - } - - if logEntry["user_id"] != "user123" { - t.Errorf("expected user_id 'user123', got %v", logEntry["user_id"]) - } - if logEntry["action"] != "login" { - t.Errorf("expected action 'login', got %v", logEntry["action"]) - } - if logEntry["message"] != "User action" { - t.Errorf("expected message 'User action', got %v", logEntry["message"]) - } -} - -// TestContextLogger_With_Chaining tests multiple With calls chained together -func TestContextLogger_With_Chaining(t *testing.T) { - resetForTesting() - var buf bytes.Buffer - cfg := &LogConfig{ - Level: slog.LevelInfo, - Format: FormatJSON, - Output: &buf, - Component: "api", - Version: "test-version", - Hostname: "test-host", - } - InitGlobalLogger(cfg) - - ctx := context.Background() - With(ctx, "field1", "value1"). - With("field2", "value2"). - With("field3", "value3"). - Info("Test chaining") - - // Parse the last JSON line - output := strings.TrimSpace(buf.String()) - lines := strings.Split(output, "\n") - lastLine := lines[len(lines)-1] - - var logEntry map[string]interface{} - if err := json.Unmarshal([]byte(lastLine), &logEntry); err != nil { - t.Fatalf("failed to parse JSON log: %v", err) - } - - if logEntry["field1"] != "value1" { - t.Errorf("expected field1 'value1', got %v", logEntry["field1"]) - } - if logEntry["field2"] != "value2" { - t.Errorf("expected field2 'value2', got %v", logEntry["field2"]) - } - if logEntry["field3"] != "value3" { - t.Errorf("expected field3 'value3', got %v", logEntry["field3"]) - } -} - -// TestContextLogger_With_AndWithError tests combining With and WithError -func TestContextLogger_With_AndWithError(t *testing.T) { - resetForTesting() - var buf bytes.Buffer - cfg := &LogConfig{ - Level: slog.LevelInfo, - Format: FormatJSON, - Output: &buf, - Component: "api", - Version: "test-version", - Hostname: "test-host", - } - InitGlobalLogger(cfg) - - ctx := context.Background() - testErr := bytes.ErrTooLarge - With(ctx, "user_id", "user456"). - With("operation", "upload"). - WithError(testErr). - Error("Upload failed") - - // Parse the last JSON line - output := strings.TrimSpace(buf.String()) - lines := strings.Split(output, "\n") - lastLine := lines[len(lines)-1] - - var logEntry map[string]interface{} - if err := json.Unmarshal([]byte(lastLine), &logEntry); err != nil { - t.Fatalf("failed to parse JSON log: %v", err) - } - - if logEntry["user_id"] != "user456" { - t.Errorf("expected user_id 'user456', got %v", logEntry["user_id"]) - } - if logEntry["operation"] != "upload" { - t.Errorf("expected operation 'upload', got %v", logEntry["operation"]) - } - if logEntry["error"] != "bytes.Buffer: too large" { - t.Errorf("expected error 'bytes.Buffer: too large', got %v", logEntry["error"]) - } -} - -// TestContextLogger_With_Empty tests With with no arguments -func TestContextLogger_With_Empty(t *testing.T) { - resetForTesting() - var buf bytes.Buffer - cfg := &LogConfig{ - Level: slog.LevelInfo, - Format: FormatJSON, - Output: &buf, - Component: "api", - Version: "test-version", - Hostname: "test-host", - } - InitGlobalLogger(cfg) - - ctx := context.Background() - // With() with no arguments should still work - With(ctx, "field1", "value1").With().Info("Test empty With") - - // Parse the last JSON line - output := strings.TrimSpace(buf.String()) - lines := strings.Split(output, "\n") - lastLine := lines[len(lines)-1] - - var logEntry map[string]interface{} - if err := json.Unmarshal([]byte(lastLine), &logEntry); err != nil { - t.Fatalf("failed to parse JSON log: %v", err) - } - - if logEntry["field1"] != "value1" { - t.Errorf("expected field1 'value1', got %v", logEntry["field1"]) - } - if logEntry["message"] != "Test empty With" { - t.Errorf("expected message 'Test empty With', got %v", logEntry["message"]) - } -} - -// TestContextLogger_With_SlogAttr tests With combined with slog.Attr -func TestContextLogger_With_SlogAttr(t *testing.T) { - resetForTesting() - var buf bytes.Buffer - cfg := &LogConfig{ - Level: slog.LevelInfo, - Format: FormatJSON, - Output: &buf, - Component: "api", - Version: "test-version", - Hostname: "test-host", - } - InitGlobalLogger(cfg) - - ctx := context.Background() - With(ctx, - slog.String("string_field", "string_value"), - slog.Int("int_field", 42), - ).With( - slog.Bool("bool_field", true), - ).Info("Test slog.Attr") - - // Parse the last JSON line - output := strings.TrimSpace(buf.String()) - lines := strings.Split(output, "\n") - lastLine := lines[len(lines)-1] - - var logEntry map[string]interface{} - if err := json.Unmarshal([]byte(lastLine), &logEntry); err != nil { - t.Fatalf("failed to parse JSON log: %v", err) - } - - if logEntry["string_field"] != "string_value" { - t.Errorf("expected string_field 'string_value', got %v", logEntry["string_field"]) - } - // JSON numbers are float64 by default - if intField, ok := logEntry["int_field"].(float64); !ok || int(intField) != 42 { - t.Errorf("expected int_field 42, got %v", logEntry["int_field"]) - } - if logEntry["bool_field"] != true { - t.Errorf("expected bool_field true, got %v", logEntry["bool_field"]) - } -} - -// TestContextLogger_With_HTTPHelpers tests With combined with HTTP helper functions -func TestContextLogger_With_HTTPHelpers(t *testing.T) { - resetForTesting() - var buf bytes.Buffer - cfg := &LogConfig{ - Level: slog.LevelInfo, - Format: FormatJSON, - Output: &buf, - Component: "api", - Version: "test-version", - Hostname: "test-host", - } - InitGlobalLogger(cfg) - - ctx := context.Background() - With(ctx, - HTTPMethod("POST"), - HTTPPath("/api/v1/users"), - ).With( - HTTPStatusCode(201), - ).Info("HTTP request handled") - - // Parse the last JSON line - output := strings.TrimSpace(buf.String()) - lines := strings.Split(output, "\n") - lastLine := lines[len(lines)-1] - - var logEntry map[string]interface{} - if err := json.Unmarshal([]byte(lastLine), &logEntry); err != nil { - t.Fatalf("failed to parse JSON log: %v", err) - } - - if logEntry["method"] != "POST" { - t.Errorf("expected method 'POST', got %v", logEntry["method"]) - } - if logEntry["path"] != "/api/v1/users" { - t.Errorf("expected path '/api/v1/users', got %v", logEntry["path"]) - } - // JSON numbers are float64 by default - if statusCode, ok := logEntry["status_code"].(float64); !ok || int(statusCode) != 201 { - t.Errorf("expected status_code 201, got %v", logEntry["status_code"]) - } -} diff --git a/pkg/logger/requestid_middleware.go b/pkg/logger/requestid_middleware.go index 43752b46..fafe77c1 100644 --- a/pkg/logger/requestid_middleware.go +++ b/pkg/logger/requestid_middleware.go @@ -2,6 +2,7 @@ package logger import ( "fmt" + "log/slog" "net/http" ) @@ -11,7 +12,7 @@ func RequestIDMiddleware(handler http.Handler) http.Handler { ctx, err := WithRequestID(r.Context()) if err != nil { wrappedErr := fmt.Errorf("request ID middleware: %w", err) - WithError(r.Context(), wrappedErr).Error("Failed to generate request ID; continuing without it") + slog.ErrorContext(r.Context(), "Failed to generate request ID; continuing without it", "error", wrappedErr) ctx = r.Context() } diff --git a/pkg/logger/text_handler.go b/pkg/logger/text_handler.go deleted file mode 100644 index 575d0973..00000000 --- a/pkg/logger/text_handler.go +++ /dev/null @@ -1,134 +0,0 @@ -package logger - -import ( - "context" - "fmt" - "io" - "log/slog" - "runtime" - "strings" - "sync" - "time" -) - -// HyperFleetTextHandler implements the HyperFleet Logging Specification text format: -// {timestamp} {LEVEL} [{component}] [{version}] [{hostname}] {message} {key=value}... -// -// Example output: -// 2026-01-09T12:30:45Z INFO [hyperfleet-api] [v1.2.3] [pod-abc] Processing request request_id=xyz cluster_id=abc123 -type HyperFleetTextHandler struct { - w io.Writer - component string - version string - hostname string - attrs []slog.Attr - level slog.Level - mu sync.Mutex -} - -// NewHyperFleetTextHandler creates a new text handler conforming to HyperFleet Logging Specification -func NewHyperFleetTextHandler( - w io.Writer, component, version, hostname string, level slog.Level, -) *HyperFleetTextHandler { - return &HyperFleetTextHandler{ - w: w, - component: component, - version: version, - hostname: hostname, - level: level, - } -} - -// Enabled reports whether the handler handles records at the given level -func (h *HyperFleetTextHandler) Enabled(_ context.Context, level slog.Level) bool { - return level >= h.level -} - -func (h *HyperFleetTextHandler) Handle(ctx context.Context, r slog.Record) error { - var buf strings.Builder - - buf.WriteString(r.Time.Format(time.RFC3339)) - buf.WriteByte(' ') - buf.WriteString(strings.ToUpper(r.Level.String())) - buf.WriteByte(' ') - fmt.Fprintf(&buf, "[%s] [%s] [%s] ", h.component, h.version, h.hostname) - buf.WriteString(r.Message) - - for _, field := range ContextFieldsRegistry { - if val, ok := field.Getter(ctx); ok { - fmt.Fprintf(&buf, " %s=%s", field.Name, formatValue(val)) - } - } - - for _, attr := range h.attrs { - fmt.Fprintf(&buf, " %s=%s", attr.Key, formatValue(attr.Value.Any())) - } - - var stackTrace []string - r.Attrs(func(a slog.Attr) bool { - if a.Key == "stack_trace" { - if frames, ok := a.Value.Any().([]runtime.Frame); ok { - stackTrace = formatStackTrace(frames) - return true - } - } - fmt.Fprintf(&buf, " %s=%s", a.Key, formatValue(a.Value.Any())) - return true - }) - - buf.WriteByte('\n') - - if len(stackTrace) > 0 { - buf.WriteString(" stack_trace:\n") - for _, frame := range stackTrace { - fmt.Fprintf(&buf, " %s\n", frame) - } - } - - h.mu.Lock() - defer h.mu.Unlock() - _, err := h.w.Write([]byte(buf.String())) - return err -} - -func (h *HyperFleetTextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - newAttrs := make([]slog.Attr, len(h.attrs)+len(attrs)) - copy(newAttrs, h.attrs) - copy(newAttrs[len(h.attrs):], attrs) - - return &HyperFleetTextHandler{ - w: h.w, - component: h.component, - version: h.version, - hostname: h.hostname, - level: h.level, - attrs: newAttrs, - } -} - -// WithGroup returns a new handler with a group name -func (h *HyperFleetTextHandler) WithGroup(name string) slog.Handler { - // For simplicity, return self (groups not needed for HyperFleet spec) - return h -} - -func formatValue(v interface{}) string { - if v == nil { - return "null" - } - - str := fmt.Sprintf("%v", v) - if strings.ContainsAny(str, " \t\n\"") { - str = strings.ReplaceAll(str, `"`, `\"`) - return fmt.Sprintf(`"%s"`, str) - } - return str -} - -func formatStackTrace(frames []runtime.Frame) []string { - result := make([]string, 0, len(frames)) - for _, frame := range frames { - result = append(result, fmt.Sprintf("%s:%d %s", frame.File, frame.Line, frame.Function)) - } - return result -} diff --git a/pkg/logger/text_handler_test.go b/pkg/logger/text_handler_test.go deleted file mode 100644 index ef4a0adf..00000000 --- a/pkg/logger/text_handler_test.go +++ /dev/null @@ -1,264 +0,0 @@ -package logger - -import ( - "bytes" - "context" - "log/slog" - "strings" - "testing" -) - -// TestHyperFleetTextHandler_BasicFormat tests basic text output format -func TestHyperFleetTextHandler_BasicFormat(t *testing.T) { - var buf bytes.Buffer - handler := NewHyperFleetTextHandler(&buf, "hyperfleet-api", "v1.2.3", "test-host", slog.LevelInfo) - - ctx := context.Background() - logger := slog.New(handler) - logger.InfoContext(ctx, "Test message", "key", "value") - - output := buf.String() - - // Check format: {timestamp} {LEVEL} [{component}] [{version}] [{hostname}] {message} {key=value}... - if !strings.Contains(output, "INFO") { - t.Errorf("expected uppercase level INFO, got: %s", output) - } - if !strings.Contains(output, "[hyperfleet-api]") { - t.Errorf("expected [hyperfleet-api], got: %s", output) - } - if !strings.Contains(output, "[v1.2.3]") { - t.Errorf("expected [v1.2.3], got: %s", output) - } - if !strings.Contains(output, "[test-host]") { - t.Errorf("expected [test-host], got: %s", output) - } - if !strings.Contains(output, "Test message") { - t.Errorf("expected 'Test message', got: %s", output) - } - if !strings.Contains(output, "key=value") { - t.Errorf("expected key=value, got: %s", output) - } -} - -// TestHyperFleetTextHandler_ContextFields tests context field extraction -func TestHyperFleetTextHandler_ContextFields(t *testing.T) { - var buf bytes.Buffer - handler := NewHyperFleetTextHandler(&buf, "hyperfleet-api", "v1.2.3", "test-host", slog.LevelInfo) - - ctx := context.Background() - ctx = context.WithValue(ctx, ReqIDKey, "test-request-123") - ctx = context.WithValue(ctx, TraceIDCtxKey, "trace-456") - ctx = context.WithValue(ctx, SpanIDCtxKey, "span-789") - ctx = context.WithValue(ctx, ClusterIDCtxKey, "cluster-abc") - - logger := slog.New(handler) - logger.InfoContext(ctx, "Processing request") - - output := buf.String() - - if !strings.Contains(output, "request_id=test-request-123") { - t.Errorf("expected request_id=test-request-123, got: %s", output) - } - if !strings.Contains(output, "trace_id=trace-456") { - t.Errorf("expected trace_id=trace-456, got: %s", output) - } - if !strings.Contains(output, "span_id=span-789") { - t.Errorf("expected span_id=span-789, got: %s", output) - } - if !strings.Contains(output, "cluster_id=cluster-abc") { - t.Errorf("expected cluster_id=cluster-abc, got: %s", output) - } -} - -// TestHyperFleetTextHandler_SpecialCharacters tests value quoting for special characters -func TestHyperFleetTextHandler_SpecialCharacters(t *testing.T) { - var buf bytes.Buffer - handler := NewHyperFleetTextHandler(&buf, "hyperfleet-api", "v1.2.3", "test-host", slog.LevelInfo) - - ctx := context.Background() - logger := slog.New(handler) - logger.InfoContext(ctx, "Test message", - "simple", "value", - "with_spaces", "hello world", - "with_quotes", `contains "quotes"`) - - output := buf.String() - - // Simple value without quotes - if !strings.Contains(output, "simple=value") { - t.Errorf("expected simple=value, got: %s", output) - } - - // Value with spaces should be quoted - if !strings.Contains(output, `with_spaces="hello world"`) { - t.Errorf("expected quoted value for spaces, got: %s", output) - } - - // Value with internal quotes should be escaped and quoted - hasQuotes := strings.Contains(output, `with_quotes="contains \"quotes\""`) || - strings.Contains(output, `with_quotes="contains \\\"quotes\\\""`) - if !hasQuotes { - t.Errorf("expected escaped quotes, got: %s", output) - } -} - -// TestHyperFleetTextHandler_LogLevels tests different log levels -func TestHyperFleetTextHandler_LogLevels(t *testing.T) { - tests := []struct { - logFunc func(*slog.Logger, context.Context, string) - name string - expectedLevel string - level slog.Level - shouldLog bool - }{ - { - name: "DEBUG enabled", level: slog.LevelDebug, - logFunc: func(l *slog.Logger, ctx context.Context, msg string) { l.DebugContext(ctx, msg) }, - expectedLevel: "DEBUG", shouldLog: true, - }, - { - name: "INFO enabled", level: slog.LevelInfo, - logFunc: func(l *slog.Logger, ctx context.Context, msg string) { l.InfoContext(ctx, msg) }, - expectedLevel: "INFO", shouldLog: true, - }, - { - name: "WARN enabled", level: slog.LevelWarn, - logFunc: func(l *slog.Logger, ctx context.Context, msg string) { l.WarnContext(ctx, msg) }, - expectedLevel: "WARN", shouldLog: true, - }, - { - name: "ERROR enabled", level: slog.LevelError, - logFunc: func(l *slog.Logger, ctx context.Context, msg string) { l.ErrorContext(ctx, msg) }, - expectedLevel: "ERROR", shouldLog: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var buf bytes.Buffer - handler := NewHyperFleetTextHandler(&buf, "hyperfleet-api", "v1.2.3", "test-host", tt.level) - - ctx := context.Background() - logger := slog.New(handler) - tt.logFunc(logger, ctx, "Test message") - - output := buf.String() - - if tt.shouldLog && !strings.Contains(output, tt.expectedLevel) { - t.Errorf("expected level %s, got: %s", tt.expectedLevel, output) - } - }) - } -} - -// TestHyperFleetTextHandler_LevelFiltering tests log level filtering -func TestHyperFleetTextHandler_LevelFiltering(t *testing.T) { - var buf bytes.Buffer - // Set handler to INFO level - handler := NewHyperFleetTextHandler(&buf, "hyperfleet-api", "v1.2.3", "test-host", slog.LevelInfo) - - ctx := context.Background() - logger := slog.New(handler) - - // DEBUG should be filtered out - logger.DebugContext(ctx, "Debug message") - if buf.Len() > 0 { - t.Errorf("expected DEBUG to be filtered, got output: %s", buf.String()) - } - - // INFO should pass through - logger.InfoContext(ctx, "Info message") - if buf.Len() == 0 { - t.Error("expected INFO to be logged") - } -} - -// TestHyperFleetTextHandler_MessageOnly tests logging with message only (no attributes) -func TestHyperFleetTextHandler_MessageOnly(t *testing.T) { - var buf bytes.Buffer - handler := NewHyperFleetTextHandler(&buf, "hyperfleet-api", "v1.2.3", "test-host", slog.LevelInfo) - - ctx := context.Background() - logger := slog.New(handler) - logger.InfoContext(ctx, "Simple message") - - output := buf.String() - - if !strings.Contains(output, "Simple message") { - t.Errorf("expected 'Simple message', got: %s", output) - } - // Should have system fields but no additional key=value pairs - if !strings.Contains(output, "[hyperfleet-api]") { - t.Errorf("expected system fields, got: %s", output) - } -} - -// TestHyperFleetTextHandler_MultipleAttributes tests logging with multiple attributes -func TestHyperFleetTextHandler_MultipleAttributes(t *testing.T) { - var buf bytes.Buffer - handler := NewHyperFleetTextHandler(&buf, "hyperfleet-api", "v1.2.3", "test-host", slog.LevelInfo) - - ctx := context.Background() - logger := slog.New(handler) - logger.InfoContext(ctx, "Multiple attributes", - "attr1", "value1", - "attr2", 42, - "attr3", true) - - output := buf.String() - - if !strings.Contains(output, "attr1=value1") { - t.Errorf("expected attr1=value1, got: %s", output) - } - if !strings.Contains(output, "attr2=42") { - t.Errorf("expected attr2=42, got: %s", output) - } - if !strings.Contains(output, "attr3=true") { - t.Errorf("expected attr3=true, got: %s", output) - } -} - -// TestHyperFleetTextHandler_EmptyContext tests logging with empty context -func TestHyperFleetTextHandler_EmptyContext(t *testing.T) { - var buf bytes.Buffer - handler := NewHyperFleetTextHandler(&buf, "hyperfleet-api", "v1.2.3", "test-host", slog.LevelInfo) - - ctx := context.Background() - logger := slog.New(handler) - logger.InfoContext(ctx, "No context fields") - - output := buf.String() - - // Should not have request_id, trace_id, etc. - if strings.Contains(output, "request_id=") { - t.Errorf("expected no request_id, got: %s", output) - } - if strings.Contains(output, "trace_id=") { - t.Errorf("expected no trace_id, got: %s", output) - } -} - -// TestFormatValue tests the formatValue helper function -func TestFormatValue(t *testing.T) { - tests := []struct { - name string - input interface{} - expected string - }{ - {"simple string", "hello", "hello"}, - {"string with spaces", "hello world", `"hello world"`}, - {"string with quotes", `say "hello"`, `"say \"hello\""`}, - {"number", 42, "42"}, - {"boolean", true, "true"}, - {"nil", nil, "null"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := formatValue(tt.input) - if result != tt.expected { - t.Errorf("formatValue(%v) = %s, expected %s", tt.input, result, tt.expected) - } - }) - } -} diff --git a/pkg/metrics/reconciliation.go b/pkg/metrics/reconciliation.go index 94106f82..50e5d253 100644 --- a/pkg/metrics/reconciliation.go +++ b/pkg/metrics/reconciliation.go @@ -20,13 +20,13 @@ import ( "context" "database/sql" "fmt" + "log/slog" "sync" "time" "github.com/prometheus/client_golang/prometheus" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) const metricsSubsystem = "hyperfleet_api" @@ -158,7 +158,7 @@ func (c *ReconciliationCollector) Collect(ch chan<- prometheus.Metric) { rows, err := c.db.QueryContext(ctx, reconciliationQuery, threshold) //nolint:gosec // compile-time SQL if err != nil { - logger.WithError(ctx, err).Error("Failed to query reconciliation metrics") + slog.ErrorContext(ctx, "Failed to query reconciliation metrics", "error", err) c.emitInvalid(ch, err) return } @@ -170,7 +170,7 @@ func (c *ReconciliationCollector) Collect(ch chan<- prometheus.Metric) { var maxDuration float64 if err := rows.Scan(&resourceType, &isDelete, &pending, &stuck, &maxDuration); err != nil { - logger.WithError(ctx, err).Error("Failed to scan reconciliation metric row") + slog.ErrorContext(ctx, "Failed to scan reconciliation metric row", "error", err) c.emitInvalid(ch, err) return } @@ -182,7 +182,7 @@ func (c *ReconciliationCollector) Collect(ch chan<- prometheus.Metric) { } if err := rows.Err(); err != nil { - logger.WithError(ctx, err).Error("Error iterating reconciliation metric rows") + slog.ErrorContext(ctx, "Error iterating reconciliation metric rows", "error", err) c.emitInvalid(ch, err) } } diff --git a/pkg/middleware/masking.go b/pkg/middleware/masking.go index 2b688725..657ad8f5 100644 --- a/pkg/middleware/masking.go +++ b/pkg/middleware/masking.go @@ -3,12 +3,12 @@ package middleware import ( "context" "encoding/json" + "log/slog" "net/http" "regexp" "strings" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) const ( @@ -99,7 +99,7 @@ func (m *MaskingMiddleware) MaskHeaders(headers http.Header) http.Header { // Handles both top-level objects and top-level arrays // If masking is disabled, returns original body unchanged // If masking is enabled but JSON parsing fails, applies text-based fallback masking -func (m *MaskingMiddleware) MaskBody(body []byte) []byte { +func (m *MaskingMiddleware) MaskBody(ctx context.Context, body []byte) []byte { if len(body) == 0 { return body } @@ -110,15 +110,14 @@ func (m *MaskingMiddleware) MaskBody(body []byte) []byte { } if len(body) > maxBodySize { - logger.With(context.Background()).Warn("Body too large for JSON masking, using text fallback") + slog.WarnContext(ctx, "Body too large for JSON masking, using text fallback") return m.maskTextFallback(body) } var data interface{} if err := json.Unmarshal(body, &data); err != nil { // JSON parsing failed - use text-based fallback masking to prevent leakage - logger.WithError(context.Background(), err). - Warn("JSON parsing failed in MaskBody, applying text-based fallback masking") + slog.WarnContext(ctx, "JSON parsing failed in MaskBody, applying text-based fallback masking", "error", err) return m.maskTextFallback(body) } @@ -127,8 +126,7 @@ func (m *MaskingMiddleware) MaskBody(body []byte) []byte { masked, err := json.Marshal(data) if err != nil { // JSON marshaling failed - use text-based fallback masking to prevent leakage - logger.WithError(context.Background(), err). - Warn("JSON marshaling failed in MaskBody, applying text-based fallback masking") + slog.WarnContext(ctx, "JSON marshaling failed in MaskBody, applying text-based fallback masking", "error", err) return m.maskTextFallback(body) } return masked diff --git a/pkg/middleware/masking_test.go b/pkg/middleware/masking_test.go index c089c9ea..96b2aaf0 100644 --- a/pkg/middleware/masking_test.go +++ b/pkg/middleware/masking_test.go @@ -1,6 +1,7 @@ package middleware import ( + "context" "encoding/json" "fmt" "net/http" @@ -244,7 +245,7 @@ func TestMaskBody(t *testing.T) { }, } m := NewMaskingMiddleware(cfg) - result := m.MaskBody([]byte(tt.body)) + result := m.MaskBody(context.Background(), []byte(tt.body)) // For JSON objects, compare as maps to handle key ordering switch { diff --git a/pkg/middleware/otel.go b/pkg/middleware/otel.go index 52f76308..fa8be98a 100644 --- a/pkg/middleware/otel.go +++ b/pkg/middleware/otel.go @@ -7,7 +7,7 @@ import ( "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel/trace" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" + hfl "github.com/openshift-hyperfleet/hyperfleet-logger" ) // OTelMiddleware extracts W3C trace context and enriches logger context @@ -27,8 +27,8 @@ func OTelMiddleware(handler http.Handler) http.Handler { if span.SpanContext().IsValid() { traceID := span.SpanContext().TraceID().String() spanID := span.SpanContext().SpanID().String() - ctx = logger.WithTraceID(ctx, traceID) - ctx = logger.WithSpanID(ctx, spanID) + ctx = hfl.WithTraceID(ctx, traceID) + ctx = hfl.WithSpanID(ctx, spanID) } r = r.WithContext(ctx) diff --git a/pkg/middleware/otel_test.go b/pkg/middleware/otel_test.go index 28b664e5..985ed076 100644 --- a/pkg/middleware/otel_test.go +++ b/pkg/middleware/otel_test.go @@ -3,6 +3,7 @@ package middleware import ( "context" "fmt" + "log/slog" "net/http" "net/http/httptest" "testing" @@ -12,6 +13,7 @@ import ( "go.opentelemetry.io/otel/sdk/trace/tracetest" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" + hfl "github.com/openshift-hyperfleet/hyperfleet-logger" ) // setupTestTracer creates an in-memory tracer for testing @@ -24,18 +26,19 @@ func setupTestTracer() (*trace.TracerProvider, *tracetest.InMemoryExporter) { return tp, exporter } +func initTestLogger(t *testing.T) { + previous := slog.Default() + t.Cleanup(func() { slog.SetDefault(previous) }) + slog.SetDefault(logger.NewLogger("test", logger.HandlerConfig{ + Level: slog.LevelDebug, Format: hfl.FormatJSON, Output: httptest.NewRecorder(), Hostname: "test", + })) +} + // TestOTelMiddleware_SpanNameUsesRouteTemplate tests that span names use route templates // to prevent cardinality explosion (H3 security fix) func TestOTelMiddleware_SpanNameUsesRouteTemplate(t *testing.T) { // Initialize logger for testing - logger.InitGlobalLogger(&logger.LogConfig{ - Level: 0, // Debug - Format: logger.FormatJSON, - Output: httptest.NewRecorder(), - Component: "test", - Version: "test", - Hostname: "test", - }) + initTestLogger(t) tp, exporter := setupTestTracer() defer func() { @@ -125,14 +128,7 @@ func TestOTelMiddleware_SpanNameUsesRouteTemplate(t *testing.T) { // TestOTelMiddleware_TraceContextExtraction tests W3C trace context extraction func TestOTelMiddleware_TraceContextExtraction(t *testing.T) { - logger.InitGlobalLogger(&logger.LogConfig{ - Level: 0, - Format: logger.FormatJSON, - Output: httptest.NewRecorder(), - Component: "test", - Version: "test", - Hostname: "test", - }) + initTestLogger(t) tp, exporter := setupTestTracer() defer func() { @@ -177,10 +173,10 @@ func TestOTelMiddleware_TraceContextExtraction(t *testing.T) { testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Extract trace IDs from logger context - if traceID, ok := logger.GetTraceID(ctx); ok { + if traceID, ok := hfl.TraceIDFromContext(ctx); ok { capturedTraceID = traceID } - if spanID, ok := logger.GetSpanID(ctx); ok { + if spanID, ok := hfl.SpanIDFromContext(ctx); ok { capturedSpanID = spanID } w.WriteHeader(http.StatusOK) @@ -219,14 +215,7 @@ func TestOTelMiddleware_TraceContextExtraction(t *testing.T) { // TestOTelMiddleware_NoTraceContext tests middleware behavior without trace context func TestOTelMiddleware_NoTraceContext(t *testing.T) { - logger.InitGlobalLogger(&logger.LogConfig{ - Level: 0, - Format: logger.FormatJSON, - Output: httptest.NewRecorder(), - Component: "test", - Version: "test", - Hostname: "test", - }) + initTestLogger(t) tp, exporter := setupTestTracer() defer func() { @@ -265,14 +254,7 @@ func TestOTelMiddleware_NoTraceContext(t *testing.T) { // TestOTelMiddleware_CardinalityPrevention demonstrates cardinality fix func TestOTelMiddleware_CardinalityPrevention(t *testing.T) { - logger.InitGlobalLogger(&logger.LogConfig{ - Level: 0, - Format: logger.FormatJSON, - Output: httptest.NewRecorder(), - Component: "test", - Version: "test", - Hostname: "test", - }) + initTestLogger(t) tp, exporter := setupTestTracer() defer func() { diff --git a/pkg/middleware/schema_validation.go b/pkg/middleware/schema_validation.go index 965ba092..93aa1bf3 100644 --- a/pkg/middleware/schema_validation.go +++ b/pkg/middleware/schema_validation.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" "regexp" @@ -22,19 +23,17 @@ func handleValidationError(w http.ResponseWriter, r *http.Request, err *errors.S } // Log validation errors as warn (client error, not server error) - logger.With(r.Context(), - "trace_id", traceID, - ).WithError(err).Warn("Validation error") + slog.WarnContext(r.Context(), + "Validation error", "trace_id", traceID, "error", err) // Write RFC 9457 Problem Details error response w.Header().Set("Content-Type", "application/problem+json") w.WriteHeader(err.HTTPCode) if encodeErr := json.NewEncoder(w).Encode(err.AsProblemDetails(r.URL.Path, traceID)); encodeErr != nil { - logger.With(r.Context(), - logger.HTTPPath(r.URL.Path), + slog.ErrorContext(r.Context(), + "Failed to encode validation error response", logger.HTTPPath(r.URL.Path), logger.HTTPMethod(r.Method), - logger.HTTPStatusCode(err.HTTPCode), - ).WithError(encodeErr).Error("Failed to encode validation error response") + logger.HTTPStatusCode(err.HTTPCode), "error", encodeErr) } } @@ -88,7 +87,7 @@ func SchemaValidationMiddleware(validator *validators.SchemaValidator) func(http return } if closeErr := r.Body.Close(); closeErr != nil { - logger.WithError(r.Context(), closeErr).Warn("Failed to close request body") + slog.WarnContext(r.Context(), "Failed to close request body", "error", closeErr) } // Restore the request body for the next handler diff --git a/pkg/services/adapter_status_validation.go b/pkg/services/adapter_status_validation.go index 9af1ec97..02d05e85 100644 --- a/pkg/services/adapter_status_validation.go +++ b/pkg/services/adapter_status_validation.go @@ -1,11 +1,12 @@ package services import ( + "context" "encoding/json" + "log/slog" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) // validateAndClassifyAdapterStatus performs all stateless validation and discard-rule @@ -13,34 +14,35 @@ import ( // aggregation should be triggered. Returns (nil, false, nil) when the update should // be silently discarded. // -// This is the shared implementation used by ResourceService. Callers provide -// the resource generation and a pre-built logger with entity-specific context fields. +// This is the shared implementation used by ResourceService. The context retains +// request correlation and adapter identity supplied by the HTTP status handler, +// with resource context added by the service. func validateAndClassifyAdapterStatus( resourceGeneration int32, adapterStatus *api.AdapterStatus, existingStatus *api.AdapterStatus, - log *logger.ContextLogger, + ctx context.Context, ) ([]api.AdapterCondition, bool, *errors.ServiceError) { if adapterStatus.ObservedGeneration > resourceGeneration { - log.Debug("Discarding adapter status update: future generation") + slog.DebugContext(ctx, "Discarding adapter status update: future generation") return nil, false, nil } if existingStatus != nil && adapterStatus.ObservedGeneration < existingStatus.ObservedGeneration { - log.Debug("Discarding adapter status update: stale generation") + slog.DebugContext(ctx, "Discarding adapter status update: stale generation") return nil, false, nil } incomingObs := AdapterObservedTime(adapterStatus) if incomingObs.IsZero() { - log.Debug("Discarding adapter status update: zero observed time") + slog.DebugContext(ctx, "Discarding adapter status update: zero observed time") return nil, false, nil } if existingStatus != nil && adapterStatus.ObservedGeneration == existingStatus.ObservedGeneration { prevObs := AdapterObservedTime(existingStatus) if !prevObs.IsZero() && incomingObs.Before(prevObs) { - log.Debug("Discarding adapter status update: stale observed time") + slog.DebugContext(ctx, "Discarding adapter status update: stale observed time") return nil, false, nil } } @@ -79,7 +81,7 @@ func validateAndClassifyAdapterStatus( if cond.Status != api.AdapterConditionTrue && cond.Status != api.AdapterConditionFalse { if existingStatus != nil { - log.Debug("Discarding adapter status update: subsequent Unknown Available") + slog.DebugContext(ctx, "Discarding adapter status update: subsequent Unknown Available") return nil, false, nil } triggerAggregation = false diff --git a/pkg/services/adapter_status_validation_test.go b/pkg/services/adapter_status_validation_test.go index 68230f1c..c386d387 100644 --- a/pkg/services/adapter_status_validation_test.go +++ b/pkg/services/adapter_status_validation_test.go @@ -10,7 +10,6 @@ import ( "gorm.io/datatypes" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) func testConditionsJSON(conditions ...api.AdapterCondition) datatypes.JSON { @@ -35,15 +34,11 @@ func adapterStatusWithGenAndTime(gen int32, observedTime time.Time) *api.Adapter } } -func testLog() *logger.ContextLogger { - return logger.With(context.Background(), "test", "true") -} - func TestValidateAndClassify_FutureGeneration_Discards(t *testing.T) { RegisterTestingT(t) status := adapterStatusWithGenAndTime(5, time.Now()) - conditions, trigger, err := validateAndClassifyAdapterStatus(3, status, nil, testLog()) + conditions, trigger, err := validateAndClassifyAdapterStatus(3, status, nil, context.Background()) Expect(err).To(BeNil()) Expect(conditions).To(BeNil()) @@ -55,7 +50,7 @@ func TestValidateAndClassify_StaleGeneration_Discards(t *testing.T) { existing := &api.AdapterStatus{ObservedGeneration: 3} status := adapterStatusWithGenAndTime(2, time.Now()) - conditions, trigger, err := validateAndClassifyAdapterStatus(5, status, existing, testLog()) + conditions, trigger, err := validateAndClassifyAdapterStatus(5, status, existing, context.Background()) Expect(err).To(BeNil()) Expect(conditions).To(BeNil()) @@ -70,7 +65,7 @@ func TestValidateAndClassify_ZeroObservedTime_Discards(t *testing.T) { ObservedGeneration: 1, Conditions: testConditionsJSON(testMandatoryConditions(api.AdapterConditionTrue)...), } - conditions, trigger, err := validateAndClassifyAdapterStatus(1, status, nil, testLog()) + conditions, trigger, err := validateAndClassifyAdapterStatus(1, status, nil, context.Background()) Expect(err).To(BeNil()) Expect(conditions).To(BeNil()) @@ -84,7 +79,7 @@ func TestValidateAndClassify_StaleObservedTime_Discards(t *testing.T) { existing := adapterStatusWithGenAndTime(1, now) status := adapterStatusWithGenAndTime(1, now.Add(-time.Minute)) - conditions, trigger, err := validateAndClassifyAdapterStatus(1, status, existing, testLog()) + conditions, trigger, err := validateAndClassifyAdapterStatus(1, status, existing, context.Background()) Expect(err).To(BeNil()) Expect(conditions).To(BeNil()) @@ -99,7 +94,7 @@ func TestValidateAndClassify_MissingMandatoryCondition_ReturnsError(t *testing.T api.AdapterCondition{Type: api.AdapterConditionTypeAvailable, Status: api.AdapterConditionTrue}, ) - _, _, err := validateAndClassifyAdapterStatus(1, status, nil, testLog()) + _, _, err := validateAndClassifyAdapterStatus(1, status, nil, context.Background()) Expect(err).ToNot(BeNil()) Expect(err.Error()).To(ContainSubstring("mandatory condition")) @@ -115,7 +110,7 @@ func TestValidateAndClassify_InvalidAvailableStatus_ReturnsError(t *testing.T) { api.AdapterCondition{Type: api.AdapterConditionTypeHealth, Status: api.AdapterConditionTrue}, ) - _, _, err := validateAndClassifyAdapterStatus(1, status, nil, testLog()) + _, _, err := validateAndClassifyAdapterStatus(1, status, nil, context.Background()) Expect(err).ToNot(BeNil()) Expect(err.Error()).To(ContainSubstring("invalid status")) @@ -127,7 +122,7 @@ func TestValidateAndClassify_FirstUnknownAvailable_Accepted(t *testing.T) { status := adapterStatusWithGenAndTime(1, time.Now()) status.Conditions = testConditionsJSON(testMandatoryConditions(api.AdapterConditionUnknown)...) - conditions, trigger, err := validateAndClassifyAdapterStatus(1, status, nil, testLog()) + conditions, trigger, err := validateAndClassifyAdapterStatus(1, status, nil, context.Background()) Expect(err).To(BeNil()) Expect(conditions).ToNot(BeNil()) @@ -141,7 +136,7 @@ func TestValidateAndClassify_SubsequentUnknownAvailable_Discards(t *testing.T) { status := adapterStatusWithGenAndTime(1, time.Now()) status.Conditions = testConditionsJSON(testMandatoryConditions(api.AdapterConditionUnknown)...) - conditions, trigger, err := validateAndClassifyAdapterStatus(1, status, existing, testLog()) + conditions, trigger, err := validateAndClassifyAdapterStatus(1, status, existing, context.Background()) Expect(err).To(BeNil()) Expect(conditions).To(BeNil()) @@ -153,7 +148,7 @@ func TestValidateAndClassify_AvailableTrue_TriggersAggregation(t *testing.T) { status := adapterStatusWithGenAndTime(1, time.Now()) - conditions, trigger, err := validateAndClassifyAdapterStatus(1, status, nil, testLog()) + conditions, trigger, err := validateAndClassifyAdapterStatus(1, status, nil, context.Background()) Expect(err).To(BeNil()) Expect(conditions).ToNot(BeNil()) @@ -166,7 +161,7 @@ func TestValidateAndClassify_AvailableFalse_TriggersAggregation(t *testing.T) { status := adapterStatusWithGenAndTime(1, time.Now()) status.Conditions = testConditionsJSON(testMandatoryConditions(api.AdapterConditionFalse)...) - conditions, trigger, err := validateAndClassifyAdapterStatus(1, status, nil, testLog()) + conditions, trigger, err := validateAndClassifyAdapterStatus(1, status, nil, context.Background()) Expect(err).To(BeNil()) Expect(conditions).ToNot(BeNil()) diff --git a/pkg/services/aggregation.go b/pkg/services/aggregation.go index 392ca066..23638970 100644 --- a/pkg/services/aggregation.go +++ b/pkg/services/aggregation.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "sort" "strings" "time" @@ -122,7 +123,8 @@ func parsePrevConditions(ctx context.Context, raw []byte) ( } var conditions []api.ResourceCondition if err := json.Unmarshal(raw, &conditions); err != nil { - logger.WithError(ctx, err).Error("Failed to unmarshal previous conditions JSON; proceeding with empty state") + slog.ErrorContext(ctx, "Failed to unmarshal previous conditions JSON; proceeding with empty state", + "error", err) return nil, nil, prevAdapterByType } for i := range conditions { @@ -176,8 +178,9 @@ func normalizeAdapterReportsForAggregation( var conditions []api.AdapterCondition if len(as.Conditions) > 0 { if err := json.Unmarshal(as.Conditions, &conditions); err != nil { - logger.With(ctx, "adapter", as.Adapter).WithError(err). - Error("Failed to unmarshal adapter status conditions; skipping adapter") + slog.ErrorContext(logger.WithAdapter(ctx, as.Adapter), + "Failed to unmarshal adapter status conditions; skipping adapter", "error", err, + ) continue } } diff --git a/pkg/services/condition_mapper.go b/pkg/services/condition_mapper.go index 6cc43f5b..acbace27 100644 --- a/pkg/services/condition_mapper.go +++ b/pkg/services/condition_mapper.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "math" "sort" "strings" @@ -223,8 +224,10 @@ func (m *ConditionMapper) evaluateRule( // Skip condition if reason exceeds max length (per JIRA AC: "skip condition") if len(reasonStr) > registry.MaxConditionReasonLength { - logger.With(ctx, "resource_kind", m.resourceKind, "condition_type", rule.conditionType). - Warn("Condition skipped: reason exceeds max length") + slog.WarnContext(ctx, + "Condition skipped: reason exceeds max length", + "resource_kind", m.resourceKind, "condition_type", rule.conditionType, + ) return nil, nil } @@ -245,8 +248,9 @@ func (m *ConditionMapper) truncateMessage( ) string { if len(messageStr) > registry.MaxConditionMessageLength { truncated := truncateUTF8(messageStr, registry.MaxConditionMessageLength) - logger.With(ctx, "resource_kind", m.resourceKind, "condition_type", rule.conditionType). - Info("Condition message truncated to max length") + slog.InfoContext(ctx, + "Condition message truncated to max length", "resource_kind", m.resourceKind, "condition_type", rule.conditionType, + ) return truncated } return messageStr @@ -321,8 +325,10 @@ func extractResourceGeneration( return 0 } if gen < math.MinInt32 || gen > math.MaxInt32 { - logger.With(ctx, "resource_kind", resourceKind, "condition_type", conditionType, "generation", gen). - Warn("Resource generation out of int32 range, using 0") + slog.WarnContext(ctx, + "Resource generation out of int32 range, "+ + "using 0", "resource_kind", resourceKind, "condition_type", conditionType, "generation", gen, + ) return 0 } return int32(gen) @@ -480,7 +486,6 @@ func buildStatusesList(ctx context.Context, statuses api.AdapterStatusList) []in func parseConditionsWithUnknownCheck( ctx context.Context, conditionsJSON []byte, - adapterName string, ) ([]map[string]interface{}, bool) { // Initialize to empty slice (not nil) so CEL receives [] instead of null conditions := make([]map[string]interface{}, 0) @@ -495,9 +500,9 @@ func parseConditionsWithUnknownCheck( // Unmarshal failure: return empty conditions array. // Degraded mode: statuses array contains entry with empty conditions, allowing // resource-level CEL expressions to still run (e.g., counting adapters). - logger.With(ctx, "adapter", adapterName). - WithError(err). - Warn("Failed to unmarshal adapter conditions JSONB, using empty conditions array") + slog.WarnContext(ctx, + "Failed to unmarshal adapter conditions JSONB, using empty conditions array", "error", err, + ) return conditions, hasUnknown } @@ -537,7 +542,7 @@ func parseConditionsWithUnknownCheck( // parseAdapterData unmarshals adapter data from JSONB to a map. // Returns empty map on unmarshal failure to maintain CEL context consistency. -func parseAdapterData(ctx context.Context, dataJSON []byte, adapterName string) map[string]interface{} { +func parseAdapterData(ctx context.Context, dataJSON []byte) map[string]interface{} { data := make(map[string]interface{}) if dataJSON == nil { @@ -546,9 +551,9 @@ func parseAdapterData(ctx context.Context, dataJSON []byte, adapterName string) if err := json.Unmarshal(dataJSON, &data); err != nil { // Reset to empty map on parse failure to maintain consistency - logger.With(ctx, "adapter", adapterName). - WithError(err). - Warn("Failed to unmarshal adapter data JSONB, using empty map") + slog.WarnContext(ctx, + "Failed to unmarshal adapter data JSONB, using empty map", "error", err, + ) return make(map[string]interface{}) } @@ -570,7 +575,8 @@ func adapterStatusToMapWithUnknownCheck(ctx context.Context, status *api.Adapter } // Parse conditions and check for Unknown status - conditions, hasUnknown := parseConditionsWithUnknownCheck(ctx, status.Conditions, status.Adapter) + ctx = logger.WithAdapter(ctx, status.Adapter) + conditions, hasUnknown := parseConditionsWithUnknownCheck(ctx, status.Conditions) // Early return if Unknown found - buildStatusesList discards the map anyway // Return nil instead of allocating a throwaway map (saves allocation on hot path) @@ -579,7 +585,7 @@ func adapterStatusToMapWithUnknownCheck(ctx context.Context, status *api.Adapter } // Parse data field from JSONB - data := parseAdapterData(ctx, status.Data, status.Adapter) + data := parseAdapterData(ctx, status.Data) statusMap := map[string]interface{}{ celKeyAdapter: status.Adapter, @@ -602,8 +608,9 @@ func resourceToMap(ctx context.Context, resource interface{}, resourceKind strin if err != nil { // Marshal failure: return empty map. // Degraded mode: CEL expressions using resource.* will receive empty object. - logger.With(ctx, "resource_kind", resourceKind).WithError(err). - Warn("Failed to marshal resource to JSON, using empty map") + slog.WarnContext(ctx, + "Failed to marshal resource to JSON, using empty map", "resource_kind", resourceKind, "error", err, + ) return make(map[string]interface{}) } @@ -611,8 +618,9 @@ func resourceToMap(ctx context.Context, resource interface{}, resourceKind strin if err := json.Unmarshal(data, &result); err != nil { // Unmarshal failure: return empty map. // Degraded mode: CEL expressions using resource.* will receive empty object. - logger.With(ctx, "resource_kind", resourceKind).WithError(err). - Warn("Failed to unmarshal resource JSON to map, using empty map") + slog.WarnContext(ctx, + "Failed to unmarshal resource JSON to map, using empty map", "resource_kind", resourceKind, "error", err, + ) return make(map[string]interface{}) } diff --git a/pkg/services/resource.go b/pkg/services/resource.go index e8bba99a..e6f2694b 100644 --- a/pkg/services/resource.go +++ b/pkg/services/resource.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "strings" "time" + hfl "github.com/openshift-hyperfleet/hyperfleet-logger" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -14,7 +16,6 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/metrics" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/tenant" @@ -609,6 +610,8 @@ func (s *sqlResourceService) ListAll( func (s *sqlResourceService) ProcessAdapterStatus( ctx context.Context, kind, resourceID string, adapterStatus *api.AdapterStatus, ) (*api.AdapterStatus, *errors.ServiceError) { + ctx = hfl.WithResourceType(ctx, kind) + ctx = hfl.WithResourceID(ctx, resourceID) trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_id", resourceID)) if svcErr := validateKind(kind); svcErr != nil { return nil, svcErr @@ -629,10 +632,8 @@ func (s *sqlResourceService) ProcessAdapterStatus( } existingStatus := findAdapterStatusInList(allStatuses, adapterStatus.Adapter) - log := logger.With(ctx, "resource_type", kind, "resource_id", resourceID, - logger.FieldAdapter, adapterStatus.Adapter) conditions, triggerAggregation, svcErr := validateAndClassifyAdapterStatus( - resource.Generation, adapterStatus, existingStatus, log, + resource.Generation, adapterStatus, existingStatus, ctx, ) if svcErr != nil { return svcErr @@ -800,6 +801,8 @@ func (s *sqlResourceService) tryHardDeleteResource( conditions []api.AdapterCondition, allStatuses api.AdapterStatusList, ) (bool, *errors.ServiceError) { + resourceCtx := hfl.WithResourceType(ctx, resource.Kind) + resourceCtx = hfl.WithResourceID(resourceCtx, resource.ID) // Quick check: does the incoming report contain Finalized=True? // If not, hard-delete is not possible regardless of other adapters. if !incomingReportedFinalized(conditions) { @@ -841,18 +844,16 @@ func (s *sqlResourceService) tryHardDeleteResource( // All checks passed — clean up associated data and hard-delete the resource. // Order matters: adapter statuses and conditions must be removed before the // resource row, since they reference it. - if err := s.adapterStatusDao.DeleteByResource(ctx, resource.Kind, resource.ID); err != nil { + if err := s.adapterStatusDao.DeleteByResource(resourceCtx, resource.Kind, resource.ID); err != nil { return false, errors.GeneralError("Failed to delete adapter statuses during hard-delete: %s", err) } - if err := s.resourceConditionDao.DeleteByResource(ctx, resource.ID); err != nil { + if err := s.resourceConditionDao.DeleteByResource(resourceCtx, resource.ID); err != nil { return false, errors.GeneralError("Failed to delete resource conditions during hard-delete: %s", err) } - if err := s.resourceDao.Delete(ctx, resource.Kind, resource.ID); err != nil { + if err := s.resourceDao.Delete(resourceCtx, resource.Kind, resource.ID); err != nil { return false, errors.GeneralError("Failed to hard-delete %s: %s", resource.Kind, err) } - - logger.With(ctx, "resource_type", resource.Kind, "resource_id", resource.ID). - Info("Hard-deleted resource after all required adapters reported Finalized=True") + slog.InfoContext(resourceCtx, "Hard-deleted resource after all required adapters reported Finalized=True") return true, nil } @@ -1008,14 +1009,15 @@ func (s *sqlResourceService) ForceDelete(ctx context.Context, kind, id, reason s func (s *sqlResourceService) forceDeleteResourceTree( ctx context.Context, resource *api.Resource, caller, reason string, ) *errors.ServiceError { + resourceCtx := hfl.WithResourceType(ctx, resource.Kind) + resourceCtx = hfl.WithResourceID(resourceCtx, resource.ID) children := registry.ChildrenOf(resource.Kind) childIDs := make([]string, 0) for _, child := range children { - items, err := s.resourceDao.FindByKindAndOwnerForUpdate(ctx, child.Kind, resource.ID) + items, err := s.resourceDao.FindByKindAndOwnerForUpdate(resourceCtx, child.Kind, resource.ID) if err != nil { - logger.With(ctx, "resource_id", resource.ID, "child_kind", child.Kind). - WithError(err).Error("Failed to find children for force-delete") + slog.ErrorContext(resourceCtx, "Failed to find children for force-delete", "child_kind", child.Kind, "error", err) return errors.GeneralError("Unable to find %s children for force-delete", child.Kind) } for _, item := range items { @@ -1025,32 +1027,27 @@ func (s *sqlResourceService) forceDeleteResourceTree( } } } - - logger.With(ctx, - "resource_kind", resource.Kind, - "resource_id", resource.ID, + slog.InfoContext(resourceCtx, + "Force-deleting resource", "caller", caller, "reason", reason, "child_resource_ids", childIDs, - ).Info("Force-deleting resource") + ) - if err := s.adapterStatusDao.DeleteByResource(ctx, resource.Kind, resource.ID); err != nil { + if err := s.adapterStatusDao.DeleteByResource(resourceCtx, resource.Kind, resource.ID); err != nil { return errors.GeneralError("Failed to delete adapter statuses during force-delete: %s", err) } - if err := s.resourceConditionDao.DeleteByResource(ctx, resource.ID); err != nil { + if err := s.resourceConditionDao.DeleteByResource(resourceCtx, resource.ID); err != nil { return errors.GeneralError("Failed to delete resource conditions during force-delete: %s", err) } // Clear inbound references before hard-deleting (FK uses ON DELETE RESTRICT). // Note: referencing resources with Min>0 on this ref type will silently // violate their required-reference invariant after this operation. - if err := s.resourceDao.ClearTargetReferences(ctx, resource.ID); err != nil { + if err := s.resourceDao.ClearTargetReferences(resourceCtx, resource.ID); err != nil { return errors.GeneralError("failed to clear references: %s", err) } - logger.With(ctx, - "resource_kind", resource.Kind, - "resource_id", resource.ID, - ).Info("Cleared inbound references for force-delete") - if err := s.resourceDao.Delete(ctx, resource.Kind, resource.ID); err != nil { + slog.InfoContext(resourceCtx, "Cleared inbound references for force-delete") + if err := s.resourceDao.Delete(resourceCtx, resource.Kind, resource.ID); err != nil { return handleDeleteError(resource.Kind, err) } diff --git a/pkg/services/resource_logging_test.go b/pkg/services/resource_logging_test.go new file mode 100644 index 00000000..2b5c50c9 --- /dev/null +++ b/pkg/services/resource_logging_test.go @@ -0,0 +1,121 @@ +package services + +import ( + "bytes" + "encoding/json" + "fmt" + "log/slog" + "sync" + "testing" + "time" + + hfl "github.com/openshift-hyperfleet/hyperfleet-logger" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" +) + +func captureServiceLogs(t *testing.T, level slog.Level) *bytes.Buffer { + t.Helper() + var output bytes.Buffer + previous := slog.Default() + slog.SetDefault(logger.NewLogger("test", logger.HandlerConfig{ + Level: level, Format: hfl.FormatJSON, Output: &output, + })) + t.Cleanup(func() { slog.SetDefault(previous) }) + return &output +} + +func TestProcessAdapterStatus_ConcurrentReportsRetainLogCorrelation(t *testing.T) { + setupAdapterStatusDescriptors() + mockDao := newMockResourceDao() + svc, _, _, _ := newTestResourceServiceWithAdapterStatus(mockDao) + resource := testResource("TestResource", "r-1", "test") + resource.Generation = 1 + mockDao.addResource(resource) + output := captureServiceLogs(t, slog.LevelDebug) + parent := hfl.WithTraceID(t.Context(), "trace-1") + parent = hfl.WithSpanID(parent, "span-1") + start := make(chan struct{}) + var wg sync.WaitGroup + for i := range 2 { + wg.Go(func() { + <-start + ctx := hfl.Set(parent, logger.ReqIDKey, fmt.Sprintf("request-%d", i)) + status := testAdapterStatusRequest(99) + status.Adapter = fmt.Sprintf("adapter-%d", i) + reportCtx := logger.WithAdapter(ctx, status.Adapter) + result, err := svc.ProcessAdapterStatus(reportCtx, resource.Kind, resource.ID, status) + if err != nil || result != nil { + t.Errorf("future report: result = %v, error = %v", result, err) + } + if _, ok := hfl.Get(ctx, logger.AdapterKey); ok { + t.Error("processing must not change the caller's adapter context") + } + }) + } + close(start) + wg.Wait() + + lines := bytes.Split(bytes.TrimSpace(output.Bytes()), []byte("\n")) + if len(lines) != 2 { + t.Fatalf("got %d log records, want 2: %s", len(lines), output.String()) + } + seen := make(map[string]bool) + for _, line := range lines { + var record map[string]any + if err := json.Unmarshal(line, &record); err != nil { + t.Fatal(err) + } + requestID, ok := record["request_id"].(string) + if !ok { + t.Fatalf("request_id missing: %s", line) + } + seen[requestID] = true + wantAdapter := map[string]string{"request-0": "adapter-0", "request-1": "adapter-1"}[requestID] + for key, want := range map[string]string{ + "adapter": wantAdapter, "resource_type": resource.Kind, "resource_id": resource.ID, + "trace_id": "trace-1", "span_id": "span-1", + "message": "Discarding adapter status update: future generation", + } { + if record[key] != want { + t.Errorf("%s = %v, want %q", key, record[key], want) + } + } + } + if !seen["request-0"] || !seen["request-1"] { + t.Errorf("missing report logs: %v", seen) + } +} + +func TestAdapterDiagnostics_IdentifyAdapterAndClassifyStacks(t *testing.T) { + output := captureServiceLogs(t, slog.LevelInfo) + ctx := logger.WithAdapter(t.Context(), "reporting-adapter") + parsePrevConditions(ctx, []byte("invalid JSON")) + normalizeAdapterReportsForAggregation(ctx, api.AdapterStatusList{ + makeAdapterStatus("other-adapter", time.Now(), 1, []byte("invalid JSON")), + }, []string{"other-adapter"}, 1) + adapterStatusToMapWithUnknownCheck(ctx, &api.AdapterStatus{ + Adapter: "mapper-adapter", Conditions: []byte("invalid JSON"), Data: []byte("invalid JSON"), + }) + + lines := bytes.Split(bytes.TrimSpace(output.Bytes()), []byte("\n")) + if len(lines) != 4 { + t.Fatalf("got %d records, want 4: %s", len(lines), output.String()) + } + for i, line := range lines { + var record map[string]any + if err := json.Unmarshal(line, &record); err != nil { + t.Fatal(err) + } + if _, hasStack := record["stack_trace"]; hasStack != (i < 2) { + t.Errorf("only ERROR diagnostics must include a stack: %s", line) + } + wantAdapter := []string{ + "reporting-adapter", "other-adapter", "mapper-adapter", "mapper-adapter", + }[i] + if record["adapter"] != wantAdapter || bytes.Count(line, []byte(`"adapter":`)) != 1 { + t.Errorf("record must identify %q once: %s", wantAdapter, line) + } + } +} diff --git a/pkg/services/util.go b/pkg/services/util.go index 94c74455..f2766eef 100755 --- a/pkg/services/util.go +++ b/pkg/services/util.go @@ -14,7 +14,6 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/auth" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) const defaultSystemUser = "system@hyperfleet.local" @@ -81,31 +80,6 @@ func handleDeleteError(resourceType string, err error) *errors.ServiceError { return errors.GeneralError("Unable to delete %s: %s", resourceType, err.Error()) } -type adapterSummary struct { - Conditions map[string]string `json:"conditions"` - Adapter string `json:"adapter"` -} - -func buildAdapterSummaries(ctx context.Context, statuses api.AdapterStatusList) []adapterSummary { - summaries := make([]adapterSummary, 0, len(statuses)) - for _, st := range statuses { - conds := make(map[string]string) - if len(st.Conditions) > 0 { - var parsed []api.AdapterCondition - if err := json.Unmarshal(st.Conditions, &parsed); err != nil { - logger.With(ctx, "adapter", st.Adapter). - WithError(err).Warn("Failed to parse adapter conditions for summary") - } else { - for _, c := range parsed { - conds[c.Type] = string(c.Status) - } - } - } - summaries = append(summaries, adapterSummary{Adapter: st.Adapter, Conditions: conds}) - } - return summaries -} - func actorFromContext(ctx context.Context) string { if caller := auth.GetUsernameFromContext(ctx); caller != "" { return caller diff --git a/pkg/services/util_test.go b/pkg/services/util_test.go index 5f966073..55e472f6 100644 --- a/pkg/services/util_test.go +++ b/pkg/services/util_test.go @@ -1,13 +1,9 @@ package services import ( - "context" - "encoding/json" "testing" . "github.com/onsi/gomega" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" ) func TestJSONEqual(t *testing.T) { @@ -135,85 +131,3 @@ func TestJSONEqual(t *testing.T) { }) } } - -func TestBuildAdapterSummaries(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - statuses api.AdapterStatusList - expected []adapterSummary - }{ - { - name: "empty input", - statuses: api.AdapterStatusList{}, - expected: []adapterSummary{}, - }, - { - name: "valid conditions", - statuses: api.AdapterStatusList{ - { - Adapter: "validation", - Conditions: mustMarshal([]api.AdapterCondition{ - {Type: "Applied", Status: "True"}, - {Type: "Available", Status: "False"}, - }), - }, - }, - expected: []adapterSummary{ - {Adapter: "validation", Conditions: map[string]string{"Applied": "True", "Available": "False"}}, - }, - }, - { - name: "empty conditions field", - statuses: api.AdapterStatusList{ - {Adapter: "provisioning", Conditions: nil}, - }, - expected: []adapterSummary{ - {Adapter: "provisioning", Conditions: map[string]string{}}, - }, - }, - { - name: "malformed JSON falls back to empty map", - statuses: api.AdapterStatusList{ - {Adapter: "broken", Conditions: []byte(`not valid json`)}, - }, - expected: []adapterSummary{ - {Adapter: "broken", Conditions: map[string]string{}}, - }, - }, - { - name: "multiple adapters", - statuses: api.AdapterStatusList{ - { - Adapter: "validation", - Conditions: mustMarshal([]api.AdapterCondition{{Type: "Applied", Status: "True"}}), - }, - { - Adapter: "provisioning", - Conditions: mustMarshal([]api.AdapterCondition{{Type: "Reconciled", Status: "False"}}), - }, - }, - expected: []adapterSummary{ - {Adapter: "validation", Conditions: map[string]string{"Applied": "True"}}, - {Adapter: "provisioning", Conditions: map[string]string{"Reconciled": "False"}}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - result := buildAdapterSummaries(context.Background(), tt.statuses) - Expect(result).To(Equal(tt.expected)) - }) - } -} - -func mustMarshal(v interface{}) []byte { - b, err := json.Marshal(v) - if err != nil { - panic(err) - } - return b -} diff --git a/pkg/telemetry/otel.go b/pkg/telemetry/otel.go index 536bb404..8165bda4 100644 --- a/pkg/telemetry/otel.go +++ b/pkg/telemetry/otel.go @@ -3,6 +3,7 @@ package telemetry import ( "context" "fmt" + "log/slog" "os" "strconv" "strings" @@ -58,12 +59,11 @@ func InitTraceProvider(ctx context.Context, serviceName, serviceVersion string) ) if err != nil { if shutdownErr := exporter.Shutdown(ctx); shutdownErr != nil { - logger.WithError(ctx, shutdownErr).Warn("Failed to shutdown exporter") + slog.WarnContext(ctx, "Failed to shutdown exporter", "error", shutdownErr) } - logger.With(ctx, - logger.FieldServiceName, serviceName, - logger.FieldServiceVersion, serviceVersion, - ).WithError(err).Error("Failed to create OpenTelemetry resource") + slog.ErrorContext(ctx, + "Failed to create OpenTelemetry resource", logger.FieldServiceName, serviceName, + logger.FieldServiceVersion, serviceVersion, "error", err) return nil, fmt.Errorf("failed to create OTel resource: %w", err) } @@ -94,7 +94,7 @@ func createExporter(ctx context.Context) (trace.SpanExporter, error) { // Create stdout exporter when no OTLP endpoint is configured exporter, err := stdouttrace.New() if err != nil { - logger.WithError(ctx, err).Error("Failed to create OpenTelemetry stdout exporter") + slog.ErrorContext(ctx, "Failed to create OpenTelemetry stdout exporter", "error", err) return nil, fmt.Errorf("failed to create OpenTelemetry stdout exporter: %w", err) } return exporter, nil @@ -107,23 +107,23 @@ func createExporter(ctx context.Context) (trace.SpanExporter, error) { // Note: http/json not yet supported - use http/protobuf exporter, err := otlptracehttp.New(ctx) if err != nil { - logger.With(ctx, logger.FieldProtocol, protocol).WithError(err).Error("Failed to create OTLP exporter") + slog.ErrorContext(ctx, "Failed to create OTLP exporter", logger.FieldProtocol, protocol, "error", err) return nil, fmt.Errorf("failed to create OTLP exporter (protocol=%s): %w", protocol, err) } return exporter, nil case "grpc", "": // Default to gRPC per standard exporter, err := otlptracegrpc.New(ctx) if err != nil { - logger.With(ctx, logger.FieldProtocol, protocol).WithError(err).Error("Failed to create OTLP exporter") + slog.ErrorContext(ctx, "Failed to create OTLP exporter", logger.FieldProtocol, protocol, "error", err) return nil, fmt.Errorf("failed to create OTLP exporter (protocol=%s): %w", protocol, err) } return exporter, nil default: // Spec-compliant values: grpc, http/protobuf - logger.With(ctx, logger.FieldProtocol, protocol).Warn("Unrecognized OTEL_EXPORTER_OTLP_PROTOCOL, using default grpc") + slog.WarnContext(ctx, "Unrecognized OTEL_EXPORTER_OTLP_PROTOCOL, using default grpc", logger.FieldProtocol, protocol) exporter, err := otlptracegrpc.New(ctx) if err != nil { - logger.With(ctx, logger.FieldProtocol, protocol).WithError(err).Error("Failed to create OTLP exporter") + slog.ErrorContext(ctx, "Failed to create OTLP exporter", logger.FieldProtocol, protocol, "error", err) return nil, fmt.Errorf("failed to create OTLP exporter (protocol=%s): %w", protocol, err) } return exporter, nil @@ -149,7 +149,7 @@ func selectSampler(ctx context.Context) trace.Sampler { case parentBasedAlwaysOff: return trace.ParentBased(trace.NeverSample()) default: - logger.With(ctx, logger.FieldSampler, samplerType).Warn("Unrecognized sampler, using default") + slog.WarnContext(ctx, "Unrecognized sampler, using default", logger.FieldSampler, samplerType) return trace.ParentBased(trace.TraceIDRatioBased(parseSamplingRate(ctx))) } } @@ -169,8 +169,9 @@ func parseSamplingRate(ctx context.Context) float64 { if parsedRate, err := strconv.ParseFloat(arg, 64); err == nil && parsedRate >= 0.0 && parsedRate <= 1.0 { rate = parsedRate } else { - logger.With(ctx, logger.FieldSamplingRate, rate, "raw_value", arg). - Warn("Invalid OTEL_TRACES_SAMPLER_ARG value, using default") + slog.WarnContext(ctx, + "Invalid OTEL_TRACES_SAMPLER_ARG value, using default", logger.FieldSamplingRate, rate, "raw_value", arg, + ) } } return rate diff --git a/pkg/tenant/middleware.go b/pkg/tenant/middleware.go index baa4ae63..5fe2e0f6 100644 --- a/pkg/tenant/middleware.go +++ b/pkg/tenant/middleware.go @@ -3,6 +3,7 @@ package tenant import ( "context" "fmt" + "log/slog" "net/http" "regexp" "strings" @@ -11,7 +12,6 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/auth" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) // maxDimensionValueLen bounds tenant dimension header values to the RFC 1123 @@ -104,7 +104,7 @@ func handleForbidden( ctx context.Context, w http.ResponseWriter, r *http.Request, reason string, values ...interface{}, ) { err := errors.Forbidden(reason, values...) - logger.WithError(ctx, err).Warn("Tenant identity rejected") + slog.WarnContext(ctx, "Tenant identity rejected", "error", err) response.WriteServiceErrorResponse(ctx, w, r, err) } diff --git a/pkg/validators/schema_validator.go b/pkg/validators/schema_validator.go index 8a015eb6..6179bfe1 100644 --- a/pkg/validators/schema_validator.go +++ b/pkg/validators/schema_validator.go @@ -3,11 +3,11 @@ package validators import ( "context" "fmt" + "log/slog" "strings" "github.com/getkin/kin-openapi/openapi3" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" ) @@ -56,11 +56,11 @@ func buildSchemasMap(doc *openapi3.T) map[string]*ResourceSchema { for _, d := range registry.WithSpecSchema() { schemaRef := doc.Components.Schemas[d.SpecSchemaName] if schemaRef == nil { - logger.With(ctx, - "schema_name", d.SpecSchemaName, + slog.WarnContext(ctx, + "OpenAPI spec schema not found, skipping validation for entity", "schema_name", d.SpecSchemaName, "kind", d.Kind, "plural", d.Plural, - ).Warn("OpenAPI spec schema not found, skipping validation for entity") + ) continue } schemas[d.Plural] = &ResourceSchema{ diff --git a/test/helper.go b/test/helper.go index a6fd23de..640e0658 100755 --- a/test/helper.go +++ b/test/helper.go @@ -18,6 +18,7 @@ import ( "github.com/brianvoe/gofakeit/v7" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" + hfl "github.com/openshift-hyperfleet/hyperfleet-logger" "github.com/spf13/cobra" "gorm.io/gorm" @@ -208,7 +209,7 @@ func NewHelper() *Helper { } if logLevel := os.Getenv("LOGLEVEL"); logLevel != "" { - logger.With(ctx, logger.FieldLogLevel, logLevel).Info("Using custom loglevel") + slog.InfoContext(ctx, "Using custom loglevel", logger.FieldLogLevel, logLevel) cfg.Logging.Level = logLevel } @@ -255,7 +256,7 @@ func NewHelper() *Helper { func (helper *Helper) Teardown() { if err := helper.closer.Close(); err != nil { - logger.WithError(context.Background(), err).Error("teardown errors") + slog.ErrorContext(context.Background(), "teardown errors", "error", err) } } @@ -268,9 +269,9 @@ func (helper *Helper) requireJWTIssuers() { // abortSetup logs msg, cleans up already-created resources via c, then panics - for unrecoverable NewHelper failures. func abortSetup(ctx context.Context, c *closer.Closer, err error, msg string) { if err != nil { - logger.WithError(ctx, err).Error(msg) + slog.ErrorContext(ctx, msg, "error", err) } else { - logger.Error(ctx, msg) + slog.ErrorContext(ctx, msg) } _ = c.Close() panic(fmt.Sprintf("test setup: %s", msg)) @@ -308,11 +309,11 @@ func (helper *Helper) startAPIServer() { abortSetup(ctx, helper.closer, err, "Unable to start Test API server") } go func() { - logger.Debug(ctx, "Test API server started") + slog.DebugContext(ctx, "Test API server started") if err := helper.APIServer.Serve(listener); err != nil { - logger.WithError(ctx, err).Error("Test API server terminated with errors") + slog.ErrorContext(ctx, "Test API server terminated with errors", "error", err) } - logger.Debug(ctx, "Test API server stopped") + slog.DebugContext(ctx, "Test API server stopped") }() } @@ -695,13 +696,11 @@ RVJUSUZJQ0FURS0tLS0tCg==` } func initTestLogger() { - cfg := &logger.LogConfig{ - Level: slog.LevelInfo, - Format: logger.FormatText, - Output: os.Stdout, - Component: "hyperfleet-api-test", - Version: "test", - Hostname: "test-host", - } - logger.InitGlobalLogger(cfg) + level, err := hfl.ParseLevel(os.Getenv("LOGLEVEL")) + if err != nil { + level = slog.LevelInfo + } + slog.SetDefault(logger.NewLogger("test", logger.HandlerConfig{ + Level: level, Format: hfl.FormatText, Output: os.Stdout, Hostname: "test-host", + })) } diff --git a/test/helper_test.go b/test/helper_test.go new file mode 100644 index 00000000..4c4f1a83 --- /dev/null +++ b/test/helper_test.go @@ -0,0 +1,18 @@ +package test + +import ( + "log/slog" + "testing" +) + +func TestInitTestLoggerHonorsLogLevel(t *testing.T) { + previous := slog.Default() + t.Cleanup(func() { slog.SetDefault(previous) }) + t.Setenv("LOGLEVEL", "DEBUG") + + initTestLogger() + + if !slog.Default().Enabled(t.Context(), slog.LevelDebug) { + t.Fatal("debug logging should be enabled when LOGLEVEL=DEBUG") + } +} diff --git a/test/integration/integration_test.go b/test/integration/integration_test.go index 81b33856..60bf6038 100755 --- a/test/integration/integration_test.go +++ b/test/integration/integration_test.go @@ -3,6 +3,7 @@ package integration import ( "context" "flag" + "log/slog" "os" "path/filepath" "runtime" @@ -24,7 +25,7 @@ func TestMain(m *testing.M) { func runTestMain(m *testing.M) int { flag.Parse() ctx := context.Background() - logger.With(ctx, "go_version", runtime.Version()).Info("Starting integration test") + slog.InfoContext(ctx, "Starting integration test", "go_version", runtime.Version()) // Set OpenAPI schema path for integration tests if not already set // This enables schema validation middleware during tests @@ -32,7 +33,7 @@ func runTestMain(m *testing.M) int { if os.Getenv("HYPERFLEET_SERVER_OPENAPI_SCHEMA_PATH") == "" { _, filename, _, ok := runtime.Caller(0) if !ok { - logger.Warn(ctx, "Failed to determine current file path via runtime.Caller, skipping schema path setup") + slog.WarnContext(ctx, "Failed to determine current file path via runtime.Caller, skipping schema path setup") } else { integrationDir := filepath.Dir(filename) testDir := filepath.Dir(integrationDir) @@ -44,8 +45,9 @@ func runTestMain(m *testing.M) int { schemaPath = filepath.Join(repoRoot, "openapi", "openapi.yaml") } _ = os.Setenv("HYPERFLEET_SERVER_OPENAPI_SCHEMA_PATH", schemaPath) - logger.With(ctx, logger.FieldSchemaPath, schemaPath). - Info("Set HYPERFLEET_SERVER_OPENAPI_SCHEMA_PATH for integration tests") + slog.InfoContext(ctx, + "Set HYPERFLEET_SERVER_OPENAPI_SCHEMA_PATH for integration tests", logger.FieldSchemaPath, schemaPath, + ) } } @@ -62,7 +64,7 @@ func runTestMain(m *testing.M) int { localExit := exitCode go func() { time.Sleep(45 * time.Second) - logger.Error(ctx, "Teardown timed out after 45s, forcing exit") + slog.ErrorContext(ctx, "Teardown timed out after 45s, forcing exit") if localExit == 0 { localExit = 1 } @@ -79,7 +81,7 @@ func terminateContainer(ctx context.Context, pgContainer *postgres.PostgresConta termCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() if err := pgContainer.Terminate(termCtx); err != nil { - logger.WithError(ctx, err).Error("Failed to terminate testcontainer") + slog.ErrorContext(ctx, "Failed to terminate testcontainer", "error", err) } } @@ -105,19 +107,19 @@ func startTestcontainer(ctx context.Context) *postgres.PostgresContainer { WithStartupTimeout(60*time.Second)), ) if err != nil { - logger.WithError(ctx, err).Error("Failed to start PostgreSQL testcontainer") + slog.ErrorContext(ctx, "Failed to start PostgreSQL testcontainer", "error", err) os.Exit(1) } host, err := pgContainer.Host(ctx) if err != nil { - logger.WithError(ctx, err).Error("Failed to get testcontainer host") + slog.ErrorContext(ctx, "Failed to get testcontainer host", "error", err) terminateContainer(ctx, pgContainer) os.Exit(1) } mappedPort, err := pgContainer.MappedPort(ctx, "5432/tcp") if err != nil { - logger.WithError(ctx, err).Error("Failed to get testcontainer mapped port") + slog.ErrorContext(ctx, "Failed to get testcontainer mapped port", "error", err) terminateContainer(ctx, pgContainer) os.Exit(1) } @@ -125,6 +127,6 @@ func startTestcontainer(ctx context.Context) *postgres.PostgresContainer { os.Setenv("HYPERFLEET_DATABASE_HOST", host) os.Setenv("HYPERFLEET_DATABASE_PORT", mappedPort.Port()) - logger.With(ctx, "host", host, "port", mappedPort.Port()).Info("PostgreSQL testcontainer started") + slog.InfoContext(ctx, "PostgreSQL testcontainer started", "host", host, "port", mappedPort.Port()) return pgContainer } From c7007999738422d10bafe7bd1b38304f43ec20a0 Mon Sep 17 00:00:00 2001 From: "red-hat-konflux-kflux-prd-rh02[bot]" <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:02:52 +0000 Subject: [PATCH 10/41] Update Konflux references digest Signed-off-by: red-hat-konflux-kflux-prd-rh02 <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> --- .tekton/hyperfleet-api-chart-push.yaml | 10 +++++----- .tekton/hyperfleet-api-chart-tag.yaml | 10 +++++----- .tekton/hyperfleet-api-push.yaml | 18 +++++++++--------- .tekton/hyperfleet-api-tag.yaml | 18 +++++++++--------- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.tekton/hyperfleet-api-chart-push.yaml b/.tekton/hyperfleet-api-chart-push.yaml index cc5daa25..87239a39 100644 --- a/.tekton/hyperfleet-api-chart-push.yaml +++ b/.tekton/hyperfleet-api-chart-push.yaml @@ -118,7 +118,7 @@ spec: - name: name value: git-clone-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.2.6@sha256:1d7ba568ae6e9e26800054f3942779de8abc80ddc48f0e44df6b1c2f098161dc + value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.2.6@sha256:a3678910c2bf9187a66a96066193cb41046fbb7097160bf2be6a374ed5e49d30 - name: kind value: task resolver: bundles @@ -144,7 +144,7 @@ spec: - name: name value: prefetch-dependencies-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.3@sha256:9fd7d251f92250d89a5465756e278e053af28c6356e5ccdedc4be87bbb13c190 + value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.3@sha256:0c386f2c26b46b978280c5c54caac26bcd524975f8f7cc06bb080ba00eae3ad4 - name: kind value: task resolver: bundles @@ -170,7 +170,7 @@ spec: - name: name value: build-helm-chart-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.3@sha256:cc59ae16baa756d23f3e5f8328cd9f8ba39a388f8eafbb9db666dea694814d25 + value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.4.0@sha256:7c7c7117e59e1c8d96a9ebc63807b5d4950d30ebabcd127eec4950a89d99eece - name: kind value: task resolver: bundles @@ -193,7 +193,7 @@ spec: - name: name value: sast-shell-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:d9b01530ce3c20287714e64980f154e28c0e94d17e2dbfbaf3c8bf77b1844b9e + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:d00c884c78489a52dc638e6ced0a01429670d0754f5b1305ad576709cf660312 - name: kind value: task resolver: bundles @@ -221,7 +221,7 @@ spec: - name: name value: sast-unicode-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:381750451fbcc86d90fa83579a48e0e6555d7cf374fe2c4af3f9262a17fc3c89 + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:15d654c9576b9c4ee1f78b09fce541acf9afe33f1c2363fd6219a79dbf62c95d - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-chart-tag.yaml b/.tekton/hyperfleet-api-chart-tag.yaml index de62fb50..cde2578c 100644 --- a/.tekton/hyperfleet-api-chart-tag.yaml +++ b/.tekton/hyperfleet-api-chart-tag.yaml @@ -147,7 +147,7 @@ spec: - name: name value: git-clone-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.2.6@sha256:1d7ba568ae6e9e26800054f3942779de8abc80ddc48f0e44df6b1c2f098161dc + value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.2.6@sha256:a3678910c2bf9187a66a96066193cb41046fbb7097160bf2be6a374ed5e49d30 - name: kind value: task resolver: bundles @@ -173,7 +173,7 @@ spec: - name: name value: prefetch-dependencies-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.3@sha256:9fd7d251f92250d89a5465756e278e053af28c6356e5ccdedc4be87bbb13c190 + value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.3@sha256:0c386f2c26b46b978280c5c54caac26bcd524975f8f7cc06bb080ba00eae3ad4 - name: kind value: task resolver: bundles @@ -203,7 +203,7 @@ spec: - name: name value: build-helm-chart-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.3@sha256:cc59ae16baa756d23f3e5f8328cd9f8ba39a388f8eafbb9db666dea694814d25 + value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.4.0@sha256:7c7c7117e59e1c8d96a9ebc63807b5d4950d30ebabcd127eec4950a89d99eece - name: kind value: task resolver: bundles @@ -226,7 +226,7 @@ spec: - name: name value: sast-shell-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:d9b01530ce3c20287714e64980f154e28c0e94d17e2dbfbaf3c8bf77b1844b9e + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:d00c884c78489a52dc638e6ced0a01429670d0754f5b1305ad576709cf660312 - name: kind value: task resolver: bundles @@ -254,7 +254,7 @@ spec: - name: name value: sast-unicode-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:381750451fbcc86d90fa83579a48e0e6555d7cf374fe2c4af3f9262a17fc3c89 + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:15d654c9576b9c4ee1f78b09fce541acf9afe33f1c2363fd6219a79dbf62c95d - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-push.yaml b/.tekton/hyperfleet-api-push.yaml index da32edd9..94a6e7bb 100644 --- a/.tekton/hyperfleet-api-push.yaml +++ b/.tekton/hyperfleet-api-push.yaml @@ -167,7 +167,7 @@ spec: - name: name value: git-clone-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.2.6@sha256:1d7ba568ae6e9e26800054f3942779de8abc80ddc48f0e44df6b1c2f098161dc + value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.2.6@sha256:a3678910c2bf9187a66a96066193cb41046fbb7097160bf2be6a374ed5e49d30 - name: kind value: task resolver: bundles @@ -193,7 +193,7 @@ spec: - name: name value: prefetch-dependencies-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.3@sha256:9fd7d251f92250d89a5465756e278e053af28c6356e5ccdedc4be87bbb13c190 + value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.3@sha256:0c386f2c26b46b978280c5c54caac26bcd524975f8f7cc06bb080ba00eae3ad4 - name: kind value: task resolver: bundles @@ -251,7 +251,7 @@ spec: - name: name value: buildah-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.12.1@sha256:dd0c817c0e3ded8ff52b6c227681ea542e17211bf85de38101b9b11029d16bac + value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.12.1@sha256:9eef3bf4c8a6a28605c713d56048fef5349bc4c791f289d8b95396ab039eda6b - name: kind value: task resolver: bundles @@ -273,7 +273,7 @@ spec: - name: name value: build-image-index - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.3.1@sha256:6ead03ca82a1b970dc60eaadae86adf374e8e70f8d951902e1fac7411dbe0550 + value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.3.1@sha256:1e2e00be81beae3645588957a8c29645660d2614db1063cfded85509bf9124fb - name: kind value: task resolver: bundles @@ -386,7 +386,7 @@ spec: - name: name value: sast-snyk-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.5@sha256:a973b32f0e11958aba08a635981e4d4e7eb200c417492371a1e692ff589b4e8f + value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.5@sha256:17b9587eba53fabaab35061275306bfe1f99661d55e14e22f62321fa7cb5ff3e - name: kind value: task resolver: bundles @@ -436,7 +436,7 @@ spec: - name: name value: sast-shell-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:d9b01530ce3c20287714e64980f154e28c0e94d17e2dbfbaf3c8bf77b1844b9e + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:d00c884c78489a52dc638e6ced0a01429670d0754f5b1305ad576709cf660312 - name: kind value: task resolver: bundles @@ -464,7 +464,7 @@ spec: - name: name value: sast-unicode-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:381750451fbcc86d90fa83579a48e0e6555d7cf374fe2c4af3f9262a17fc3c89 + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:15d654c9576b9c4ee1f78b09fce541acf9afe33f1c2363fd6219a79dbf62c95d - name: kind value: task resolver: bundles @@ -486,7 +486,7 @@ spec: - name: name value: apply-tags - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.3.1@sha256:b1c71d83be8e6d0e44f9b6e58e1ad491396d4c287be9291802c9be69d9441f2f + value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.3.1@sha256:c177fc2b2482b1299e713c51bf258eba4972c016ef260d9f45335c2550dc7b2b - name: kind value: task resolver: bundles @@ -526,7 +526,7 @@ spec: - name: name value: rpms-signature-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:64bb1cf7d875157e68043a411f00e7852afffc68f50881fb041ba994109f4763 + value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:d9df5d325f81a9fa132cb597c465aa9210b9b0fa3716b85b32be98d92c9c881a - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-tag.yaml b/.tekton/hyperfleet-api-tag.yaml index 77d6d9b1..065c768b 100644 --- a/.tekton/hyperfleet-api-tag.yaml +++ b/.tekton/hyperfleet-api-tag.yaml @@ -197,7 +197,7 @@ spec: - name: name value: git-clone-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.2.6@sha256:1d7ba568ae6e9e26800054f3942779de8abc80ddc48f0e44df6b1c2f098161dc + value: quay.io/konflux-ci/tekton-catalog/task-git-clone-oci-ta:0.2.6@sha256:a3678910c2bf9187a66a96066193cb41046fbb7097160bf2be6a374ed5e49d30 - name: kind value: task resolver: bundles @@ -223,7 +223,7 @@ spec: - name: name value: prefetch-dependencies-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.3@sha256:9fd7d251f92250d89a5465756e278e053af28c6356e5ccdedc4be87bbb13c190 + value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.3@sha256:0c386f2c26b46b978280c5c54caac26bcd524975f8f7cc06bb080ba00eae3ad4 - name: kind value: task resolver: bundles @@ -281,7 +281,7 @@ spec: - name: name value: buildah-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.12.1@sha256:dd0c817c0e3ded8ff52b6c227681ea542e17211bf85de38101b9b11029d16bac + value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.12.1@sha256:9eef3bf4c8a6a28605c713d56048fef5349bc4c791f289d8b95396ab039eda6b - name: kind value: task resolver: bundles @@ -303,7 +303,7 @@ spec: - name: name value: build-image-index - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.3.1@sha256:6ead03ca82a1b970dc60eaadae86adf374e8e70f8d951902e1fac7411dbe0550 + value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.3.1@sha256:1e2e00be81beae3645588957a8c29645660d2614db1063cfded85509bf9124fb - name: kind value: task resolver: bundles @@ -416,7 +416,7 @@ spec: - name: name value: sast-snyk-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.5@sha256:a973b32f0e11958aba08a635981e4d4e7eb200c417492371a1e692ff589b4e8f + value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.5@sha256:17b9587eba53fabaab35061275306bfe1f99661d55e14e22f62321fa7cb5ff3e - name: kind value: task resolver: bundles @@ -466,7 +466,7 @@ spec: - name: name value: sast-shell-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:d9b01530ce3c20287714e64980f154e28c0e94d17e2dbfbaf3c8bf77b1844b9e + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:d00c884c78489a52dc638e6ced0a01429670d0754f5b1305ad576709cf660312 - name: kind value: task resolver: bundles @@ -494,7 +494,7 @@ spec: - name: name value: sast-unicode-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:381750451fbcc86d90fa83579a48e0e6555d7cf374fe2c4af3f9262a17fc3c89 + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:15d654c9576b9c4ee1f78b09fce541acf9afe33f1c2363fd6219a79dbf62c95d - name: kind value: task resolver: bundles @@ -516,7 +516,7 @@ spec: - name: name value: apply-tags - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.3.1@sha256:b1c71d83be8e6d0e44f9b6e58e1ad491396d4c287be9291802c9be69d9441f2f + value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.3.1@sha256:c177fc2b2482b1299e713c51bf258eba4972c016ef260d9f45335c2550dc7b2b - name: kind value: task resolver: bundles @@ -556,7 +556,7 @@ spec: - name: name value: rpms-signature-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:64bb1cf7d875157e68043a411f00e7852afffc68f50881fb041ba994109f4763 + value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:d9df5d325f81a9fa132cb597c465aa9210b9b0fa3716b85b32be98d92c9c881a - name: kind value: task resolver: bundles From c62521fb36a0f71ae7e4fa028d38e8e8d602e21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michaela=20Pa=C5=99ilov=C3=A1?= Date: Tue, 22 Sep 2026 11:46:25 +0200 Subject: [PATCH 11/41] HYPERFLEET-1491 - fix: compute observed_generation from adapter reports --- docs/api-operator-guide.md | 6 +-- docs/api-resources.md | 10 ++-- pkg/services/aggregation.go | 37 +++++++++++++-- pkg/services/aggregation_test.go | 76 +++++++++++++++++++++++++++---- pkg/services/resource_test.go | 3 +- test/integration/clusters_test.go | 52 +++++++++++++++++++++ 6 files changed, 161 insertions(+), 23 deletions(-) diff --git a/docs/api-operator-guide.md b/docs/api-operator-guide.md index ef8bf45e..e9c1ad35 100644 --- a/docs/api-operator-guide.md +++ b/docs/api-operator-guide.md @@ -503,18 +503,18 @@ These are API examples for a resource and resource statuses: When a resource is created: - Initial `generation` is 1 and aggregated conditions are evaluated -- `observed_generation` for `Ready` and `LastKnownReconciled` aggregated conditions is 1 +- `observed_generation` for `Ready` and `LastKnownReconciled` aggregated conditions is 0 (no adapter has reported yet) - `last_updated_time` and `last_transition_time` for `Ready` and `LastKnownReconciled` aggregated conditions is `resource.last_updated_time` When a resource is changed: - `resource.generation` gets incremented and aggregated conditions are re-evaluated -- `status.conditions[type==Ready].observed_generation` always follows `resource.generation` +- `status.conditions[type==Ready].observed_generation` follows `resource.generation` only when `Ready` is `True`; otherwise it stays at the max adapter-reported generation - `status.conditions[type==LastKnownReconciled].observed_generation` changes when all required adapters `condition[type==Available].observed_generation==resource.generation` otherwise remains unchanged. ##### Computing `observed_generation` -- For `Ready` it always matches `resource.generation` +- For `Ready`, it matches `resource.generation` only when status is `True`; otherwise it's the max adapter-reported generation, or 0 if none have reported - For `LastKnownReconciled`: - If all required adapters have a common `observed_generation` it will match the common value - If required adapters have mixed `observed_generation` diff --git a/docs/api-resources.md b/docs/api-resources.md index 090b41e0..84a6656d 100644 --- a/docs/api-resources.md +++ b/docs/api-resources.md @@ -79,7 +79,7 @@ PUT /api/hyperfleet/v1/clusters/{cluster_id}/statuses "status": "False", "reason": "ReconciledMissingAdapters", "message": "Required adapters have not yet reported status", - "observed_generation": 1, + "observed_generation": 0, "created_time": "2025-01-01T00:00:00Z", "last_updated_time": "2025-01-01T00:00:00Z", "last_transition_time": "2025-01-01T00:00:00Z" @@ -89,7 +89,7 @@ PUT /api/hyperfleet/v1/clusters/{cluster_id}/statuses "status": "False", "reason": "AdaptersMissingReports", "message": "Required adapters have not yet reported status", - "observed_generation": 1, + "observed_generation": 0, "created_time": "2025-01-01T00:00:00Z", "last_updated_time": "2025-01-01T00:00:00Z", "last_transition_time": "2025-01-01T00:00:00Z" @@ -319,7 +319,7 @@ Updates a cluster's `spec` and/or `labels`. Only the fields provided in the requ "status": "False", "reason": "ReconciledMissingAdapters", "message": "Required adapters have not yet reported status", - "observed_generation": 2, + "observed_generation": 1, "created_time": "2025-01-01T00:00:00Z", "last_updated_time": "2025-01-01T12:00:00Z", "last_transition_time": "2025-01-01T12:00:00Z" @@ -469,7 +469,7 @@ PUT /api/hyperfleet/v1/clusters/{cluster_id}/nodepools/{nodepool_id}/statuses "status": "False", "reason": "ReconciledMissingAdapters", "message": "Required adapters have not yet reported status", - "observed_generation": 1, + "observed_generation": 0, "created_time": "2025-01-01T00:00:00Z", "last_updated_time": "2025-01-01T00:00:00Z", "last_transition_time": "2025-01-01T00:00:00Z" @@ -479,7 +479,7 @@ PUT /api/hyperfleet/v1/clusters/{cluster_id}/nodepools/{nodepool_id}/statuses "status": "False", "reason": "AdaptersMissingReports", "message": "Required adapters have not yet reported status", - "observed_generation": 1, + "observed_generation": 0, "created_time": "2025-01-01T00:00:00Z", "last_updated_time": "2025-01-01T00:00:00Z", "last_transition_time": "2025-01-01T00:00:00Z" diff --git a/pkg/services/aggregation.go b/pkg/services/aggregation.go index 23638970..60f9f5bf 100644 --- a/pkg/services/aggregation.go +++ b/pkg/services/aggregation.go @@ -336,10 +336,12 @@ func computeReconciled( createdTime = prevCondition.CreatedTime } + observedGen := computeReconciledObservedGeneration(resourceGen, status, requiredAdapters, snapshotsByAdapter) + return api.ResourceCondition{ Type: api.ResourceConditionTypeReconciled, Status: status, - ObservedGeneration: resourceGen, + ObservedGeneration: observedGen, Reason: strPtr(reason), Message: strPtr(message), CreatedTime: createdTime, @@ -348,6 +350,33 @@ func computeReconciled( } } +// computeReconciledObservedGeneration returns resourceGen when status is True, otherwise the max +// generation any required adapter has actually reported (0 if none have reported). +// Trusts status==True without re-checking snapshotsByAdapter, its only safe as long as callers +// derive status the same way computeReconciled does. +func computeReconciledObservedGeneration( + resourceGen int32, + status api.ResourceConditionStatus, + requiredAdapters []string, + snapshotsByAdapter map[string]adapterAvailableSnapshot, +) int32 { + if status == api.ConditionTrue { + return resourceGen + } + + maxGeneration := int32(0) + for _, name := range requiredAdapters { + snap, ok := snapshotsByAdapter[name] + if !ok { + continue + } + if snap.observedGeneration > maxGeneration { + maxGeneration = snap.observedGeneration + } + } + return maxGeneration +} + func computeReconciledLastUpdatedTime( resourceGen int32, refTime time.Time, @@ -541,7 +570,7 @@ func computeLastKnownReconciledObservedGeneration( mixed bool, ) int32 { if len(required) == 0 { - return 1 + return 0 } if status == api.ConditionTrue { @@ -551,7 +580,7 @@ func computeLastKnownReconciledObservedGeneration( if prev != nil { return prev.ObservedGeneration } - return 1 + return 0 } // False @@ -569,7 +598,7 @@ func computeLastKnownReconciledObservedGeneration( if prev != nil { return prev.ObservedGeneration } - return 1 + return 0 } return maxG } diff --git a/pkg/services/aggregation_test.go b/pkg/services/aggregation_test.go index 11ccf723..c8e9e46d 100644 --- a/pkg/services/aggregation_test.go +++ b/pkg/services/aggregation_test.go @@ -544,11 +544,43 @@ func TestComputeReconciled(t *testing.T) { } }) - t.Run("ObservedGeneration always equals resourceGen", func(t *testing.T) { + t.Run("ObservedGeneration is 0 when no adapter has reported", func(t *testing.T) { t.Parallel() cond := computeReconciled(5, aggTRef, nil, nil, nil, map[string]adapterAvailableSnapshot{}, false) - if cond.ObservedGeneration != 5 { - t.Errorf("ObservedGeneration got %d, want 5", cond.ObservedGeneration) + if cond.ObservedGeneration != 0 { + t.Errorf("ObservedGeneration got %d, want 0", cond.ObservedGeneration) + } + }) + + t.Run("ObservedGeneration is max adapter gen when not fully reconciled", func(t *testing.T) { + t.Parallel() + required := []string{"a", "b"} + byAdapter := map[string]adapterAvailableSnapshot{ + "a": snap(1, true, aggT1), + "b": snap(2, true, aggT2), + } + cond := computeReconciled(3, aggTRef, nil, nil, required, byAdapter, false) + if cond.Status != api.ConditionFalse { + t.Fatalf("got %v, want False", cond.Status) + } + if cond.ObservedGeneration != 2 { + t.Errorf("ObservedGeneration got %d, want 2 (max of reported gens)", cond.ObservedGeneration) + } + }) + + t.Run("ObservedGeneration equals resourceGen only when status is True", func(t *testing.T) { + t.Parallel() + required := []string{"a", "b"} + byAdapter := map[string]adapterAvailableSnapshot{ + "a": snap(2, true, aggT1), + "b": snap(2, true, aggT2), + } + cond := computeReconciled(2, aggTRef, nil, nil, required, byAdapter, false) + if cond.Status != api.ConditionTrue { + t.Fatalf("got %v, want True", cond.Status) + } + if cond.ObservedGeneration != 2 { + t.Errorf("ObservedGeneration got %d, want 2 (resourceGen)", cond.ObservedGeneration) } }) @@ -810,6 +842,9 @@ func TestComputeLastKnownReconciled(t *testing.T) { if cond.Reason == nil || *cond.Reason != reasonLKRMissingReports { t.Errorf("Reason got %v, want %s", cond.Reason, reasonLKRMissingReports) } + if cond.ObservedGeneration != 0 { + t.Errorf("ObservedGeneration got %d, want 0 (no adapters exist)", cond.ObservedGeneration) + } }) t.Run("required adapter missing from byAdapter → False", func(t *testing.T) { @@ -930,6 +965,18 @@ func TestComputeLastKnownReconciled(t *testing.T) { } }) + t.Run("no adapter has reported: observed_generation is 0", func(t *testing.T) { + t.Parallel() + required := []string{"a", "b"} + cond := computeLastKnownReconciled(aggTRef, nil, required, map[string]adapterAvailableSnapshot{}) + if cond.Status != api.ConditionFalse { + t.Errorf("Status got %v, want False", cond.Status) + } + if cond.ObservedGeneration != 0 { + t.Errorf("ObservedGeneration got %d, want 0 (none have reported)", cond.ObservedGeneration) + } + }) + t.Run("False with mixed gens: observed_generation is max adapter gen", func(t *testing.T) { t.Parallel() // "a" False at gen 1, "b" True at gen 2 → allTrue=false → False. @@ -1012,10 +1059,11 @@ func TestAggregateResourceStatus(t *testing.T) { return b } - t.Run("initial creation: req adapters, no reports → all False, observed_gen=1, times=refTime", func(t *testing.T) { + t.Run("initial creation: req adapters, no reports → all False, observed_gen=0, times=refTime", func(t *testing.T) { t.Parallel() // Doc: when resource is created at gen=1, no adapter has reported yet. - // observed_generation for Reconciled and LastKnownReconciled must be 1. + // observed_generation for Reconciled and LastKnownReconciled must be 0, since no adapter + // has observed the resource yet // last_updated_time and last_transition_time must equal resource.last_updated_time (refTime). required := []string{"a", "b"} in := AggregateResourceStatusInput{ @@ -1030,11 +1078,11 @@ func TestAggregateResourceStatus(t *testing.T) { if avail.Status != api.ConditionFalse { t.Errorf("avail.Status got %v, want False", avail.Status) } - if reconciled.ObservedGeneration != 1 { - t.Errorf("reconciled.ObservedGeneration got %d, want 1", reconciled.ObservedGeneration) + if reconciled.ObservedGeneration != 0 { + t.Errorf("reconciled.ObservedGeneration got %d, want 0", reconciled.ObservedGeneration) } - if avail.ObservedGeneration != 1 { - t.Errorf("avail.ObservedGeneration got %d, want 1", avail.ObservedGeneration) + if avail.ObservedGeneration != 0 { + t.Errorf("avail.ObservedGeneration got %d, want 0", avail.ObservedGeneration) } if !reconciled.LastUpdatedTime.Equal(aggTRef) { t.Errorf("reconciled.LastUpdatedTime got %v, want refTime=%v", reconciled.LastUpdatedTime, aggTRef) @@ -1063,6 +1111,12 @@ func TestAggregateResourceStatus(t *testing.T) { if avail.Status != api.ConditionFalse { t.Errorf("avail: got %v, want False", avail.Status) } + if reconciled.ObservedGeneration != 0 { + t.Errorf("reconciled.ObservedGeneration got %d, want 0 (no adapters exist)", reconciled.ObservedGeneration) + } + if avail.ObservedGeneration != 0 { + t.Errorf("avail.ObservedGeneration got %d, want 0 (no adapters exist)", avail.ObservedGeneration) + } }) t.Run("all adapters True at current generation → both True", func(t *testing.T) { @@ -1108,6 +1162,10 @@ func TestAggregateResourceStatus(t *testing.T) { if avail.Status != api.ConditionTrue { t.Errorf("avail: got %v, want True (all True at same old gen)", avail.Status) } + if reconciled.ObservedGeneration != 1 { + t.Errorf("reconciled.ObservedGeneration got %d, want 1 (max adapter-reported gen)", + reconciled.ObservedGeneration) + } }) t.Run("split generations, no prev → Reconciled False, Available False", func(t *testing.T) { diff --git a/pkg/services/resource_test.go b/pkg/services/resource_test.go index 9369bb4d..20ed2d4a 100644 --- a/pkg/services/resource_test.go +++ b/pkg/services/resource_test.go @@ -3227,8 +3227,7 @@ func TestResourceService_Patch_ReconciledFlipsToFalse(t *testing.T) { Expect(recon).ToNot(BeNil()) Expect(recon.Status).To(Equal(api.ConditionFalse), "Reconciled must flip to False after Patch bumps generation") - Expect(recon.ObservedGeneration).To(Equal(int32(2)), - "ObservedGeneration should reflect the new resource generation") + Expect(recon.ObservedGeneration).To(Equal(int32(1)), "ObservedGeneration should stay at what adapter-a reported") } func TestResourceService_Patch_ConsecutivePatchKeepsReconciledFalse(t *testing.T) { diff --git a/test/integration/clusters_test.go b/test/integration/clusters_test.go index 99c1dee8..d220e356 100644 --- a/test/integration/clusters_test.go +++ b/test/integration/clusters_test.go @@ -182,6 +182,58 @@ func TestClusterPatch_SetReconciledFalse(t *testing.T) { "Reconciled must be False after generation increment") } +func TestClusterPatch_ReconciledObservedGeneration(t *testing.T) { + h, client := test.RegisterIntegration(t) + + account := h.NewRandAccount() + ctx := h.NewAuthenticatedContext(account) + + cluster, err := h.Factories.NewClusters(h.NewID()) + Expect(err).NotTo(HaveOccurred()) + + // Only "validation" reports; the other required adapters never do, so Reconciled stays False + statusInput := newAdapterStatusRequest( + "validation", + cluster.Generation, + []openapi.ConditionRequest{ + {Type: api.AdapterConditionTypeAvailable, Status: openapi.AdapterConditionStatusTrue}, + {Type: api.AdapterConditionTypeApplied, Status: openapi.AdapterConditionStatusTrue}, + {Type: api.AdapterConditionTypeHealth, Status: openapi.AdapterConditionStatusTrue}, + }, + nil, + ) + statusResp, err := client.PutClusterStatusesWithResponse( + ctx, cluster.ID, + openapi.PutClusterStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(statusResp.StatusCode()).To(Equal(http.StatusCreated)) + + // Bump the generation via Patch + newLabels := map[string]string{"env": "staging"} + patchResp, err := client.PatchClusterByIdWithResponse( + ctx, cluster.ID, + openapi.PatchClusterByIdJSONRequestBody{Labels: &newLabels}, + test.WithAuthToken(ctx), + ) + Expect(err).NotTo(HaveOccurred()) + Expect(patchResp.StatusCode()).To(Equal(http.StatusOK)) + + updated := patchResp.JSON200 + Expect(updated.Generation).To(Equal(cluster.Generation+1), "Generation should increment when labels change") + + var reconciledCond *openapi.ResourceCondition + for i := range updated.Status.Conditions { + if updated.Status.Conditions[i].Type == api.ResourceConditionTypeReconciled { + reconciledCond = &updated.Status.Conditions[i] + break + } + } + Expect(reconciledCond).NotTo(BeNil(), "Expected Reconciled condition in response") + Expect(reconciledCond.Status).To(Equal(openapi.ResourceConditionStatusFalse), "Reconciled must be False") + Expect(reconciledCond.ObservedGeneration).To(Equal(cluster.Generation), "ObservedGeneration should stay at gen 1") +} + func TestClusterPaging(t *testing.T) { h, client := test.RegisterIntegration(t) From 4093b90c09fba82e620723fff6b326ffc6c59478 Mon Sep 17 00:00:00 2001 From: Dmitrii Andreev Date: Wed, 23 Sep 2026 17:59:22 -0500 Subject: [PATCH 12/41] HYPERFLEET-1662 - fix: Prevent force-delete from violating mandatory reference invariants --- pkg/dao/adapter_status.go | 10 + pkg/dao/mocks/resource.go | 35 +++ pkg/dao/resource.go | 91 +++++++ pkg/services/resource.go | 212 +++++++++++---- pkg/services/resource_logging_test.go | 84 ++++++ pkg/services/resource_test.go | 194 ++++++++++++- pkg/services/test_helpers_test.go | 23 +- test/integration/resource_references_test.go | 270 ++++++++++++++++++- 8 files changed, 855 insertions(+), 64 deletions(-) diff --git a/pkg/dao/adapter_status.go b/pkg/dao/adapter_status.go index b95ae8e9..b7adac1d 100644 --- a/pkg/dao/adapter_status.go +++ b/pkg/dao/adapter_status.go @@ -15,6 +15,7 @@ type AdapterStatusDao interface { Upsert(ctx context.Context, adapterStatus *api.AdapterStatus, existing *api.AdapterStatus) (*api.AdapterStatus, error) Delete(ctx context.Context, id string) error DeleteByResource(ctx context.Context, resourceType, resourceID string) error + DeleteByResourceIDs(ctx context.Context, resourceIDs []string) error FindByResource(ctx context.Context, resourceType, resourceID string) (api.AdapterStatusList, error) FindByResourceIDs(ctx context.Context, resourceType string, resourceIDs []string) (api.AdapterStatusList, error) FindByResourcePaginated( @@ -117,6 +118,15 @@ func (d *sqlAdapterStatusDao) DeleteByResource(ctx context.Context, resourceType return nil } +func (d *sqlAdapterStatusDao) DeleteByResourceIDs(ctx context.Context, resourceIDs []string) error { + if len(resourceIDs) == 0 { + return nil + } + return d.sessionFactory.New(ctx). + Where("resource_id IN ?", resourceIDs). + Delete(&api.AdapterStatus{}).Error +} + func (d *sqlAdapterStatusDao) FindByResource( ctx context.Context, resourceType, resourceID string, ) (api.AdapterStatusList, error) { diff --git a/pkg/dao/mocks/resource.go b/pkg/dao/mocks/resource.go index 53824cea..bbd4ba77 100644 --- a/pkg/dao/mocks/resource.go +++ b/pkg/dao/mocks/resource.go @@ -28,6 +28,10 @@ func (d *resourceDaoMock) GetForUpdate(ctx context.Context, kind, id string) (*a return d.Get(ctx, kind, id) } +func (d *resourceDaoMock) GetRowForUpdate(ctx context.Context, kind, id string) (*api.Resource, error) { + return d.Get(ctx, kind, id) +} + func (d *resourceDaoMock) GetByOwner(_ context.Context, kind, id, ownerID string) (*api.Resource, error) { for _, r := range d.resources { if r.ID == id && r.Kind == kind && r.OwnerID != nil && *r.OwnerID == ownerID { @@ -63,6 +67,19 @@ func (d *resourceDaoMock) Delete(_ context.Context, kind, id string) error { return nil } +func (d *resourceDaoMock) DeleteIDs(ctx context.Context, ids []string) error { + for _, id := range ids { + for _, r := range append(api.ResourceList(nil), d.resources...) { + if r.ID == id { + if err := d.Delete(ctx, r.Kind, id); err != nil { + return err + } + } + } + } + return nil +} + func (d *resourceDaoMock) ExistsByOwner(_ context.Context, kind, ownerID string) (bool, error) { for _, r := range d.resources { if r.Kind == kind && r.OwnerID != nil && *r.OwnerID == ownerID && r.DeletedTime == nil { @@ -114,6 +131,16 @@ func (d *resourceDaoMock) FindByKindAndOwnerForUpdate( return d.FindByKindAndOwner(ctx, kind, ownerID) } +func (d *resourceDaoMock) FindChildrenForUpdate(_ context.Context, ownerID string) (api.ResourceList, error) { + var result api.ResourceList + for _, r := range d.resources { + if r.OwnerID != nil && *r.OwnerID == ownerID { + result = append(result, r) + } + } + return result, nil +} + func (d *resourceDaoMock) GetByID(_ context.Context, id string) (*api.Resource, error) { for _, r := range d.resources { if r.ID == id { @@ -123,6 +150,14 @@ func (d *resourceDaoMock) GetByID(_ context.Context, id string) (*api.Resource, return nil, gorm.ErrRecordNotFound } +func (d *resourceDaoMock) FindExternalReferenceCounts( + _ context.Context, _, _ []string, +) ([]dao.ExternalReferenceCount, error) { + return nil, nil +} + +func (d *resourceDaoMock) DeleteReferencesByTargets(_ context.Context, _ []string) error { return nil } + func (d *resourceDaoMock) ReplaceReferences(_ context.Context, _ string, _ []api.ResourceReference) error { return nil } diff --git a/pkg/dao/resource.go b/pkg/dao/resource.go index c07f122d..d2402345 100644 --- a/pkg/dao/resource.go +++ b/pkg/dao/resource.go @@ -14,22 +14,34 @@ import ( type ResourceDao interface { Get(ctx context.Context, kind, id string) (*api.Resource, error) GetForUpdate(ctx context.Context, kind, id string) (*api.Resource, error) + GetRowForUpdate(ctx context.Context, kind, id string) (*api.Resource, error) GetByOwner(ctx context.Context, kind, id, ownerID string) (*api.Resource, error) Create(ctx context.Context, resource *api.Resource) (*api.Resource, error) Save(ctx context.Context, resource *api.Resource) error Delete(ctx context.Context, kind, id string) error + DeleteIDs(ctx context.Context, ids []string) error ExistsByOwner(ctx context.Context, kind, ownerID string) (bool, error) ExistsSoftDeletedByOwner(ctx context.Context, kinds []string, ownerID string) (bool, error) FindByKind(ctx context.Context, kind string) (api.ResourceList, error) FindByKindAndOwner(ctx context.Context, kind, ownerID string) (api.ResourceList, error) FindByKindAndOwnerForUpdate(ctx context.Context, kind, ownerID string) (api.ResourceList, error) + FindChildrenForUpdate(ctx context.Context, ownerID string) (api.ResourceList, error) GetByID(ctx context.Context, id string) (*api.Resource, error) ReplaceReferences(ctx context.Context, sourceID string, refs []api.ResourceReference) error + FindExternalReferenceCounts(ctx context.Context, targetIDs, sourceIDs []string) ([]ExternalReferenceCount, error) + DeleteReferencesByTargets(ctx context.Context, targetIDs []string) error FindReferencers(ctx context.Context, targetID string) ([]api.ResourceSummary, error) ClearTargetReferences(ctx context.Context, targetID string) error FindSourceIDsByRef(ctx context.Context, refType, targetID string) ([]string, error) } +// ExternalReferenceCount describes a surviving source reference type affected by deletion. +type ExternalReferenceCount struct { + SourceKind string + RefType string + Remaining int64 +} + var _ ResourceDao = &sqlResourceDao{} type sqlResourceDao struct { @@ -61,6 +73,17 @@ func (d *sqlResourceDao) GetForUpdate(ctx context.Context, kind, id string) (*ap return &resource, nil } +// GetRowForUpdate locks a resource without loading its associations. +func (d *sqlResourceDao) GetRowForUpdate(ctx context.Context, kind, id string) (*api.Resource, error) { + g2 := tenant.ScopeDB(d.sessionFactory.New(ctx), ctx) + var resource api.Resource + if err := g2.Clauses(clause.Locking{Strength: "UPDATE"}). + Take(&resource, "kind = ? AND id = ?", kind, id).Error; err != nil { + return nil, err + } + return &resource, nil +} + func (d *sqlResourceDao) GetByOwner(ctx context.Context, kind, id, ownerID string) (*api.Resource, error) { g2 := tenant.ScopeDB(d.sessionFactory.New(ctx), ctx) var resource api.Resource @@ -106,6 +129,15 @@ func (d *sqlResourceDao) Delete(ctx context.Context, kind, id string) error { return nil } +// DeleteIDs removes the given resource IDs within the caller's tenant scope. +func (d *sqlResourceDao) DeleteIDs(ctx context.Context, ids []string) error { + if len(ids) == 0 { + return nil + } + return tenant.ScopeDB(d.sessionFactory.New(ctx), ctx). + Omit(clause.Associations).Where("id IN ?", ids).Delete(&api.Resource{}).Error +} + func (d *sqlResourceDao) ExistsByOwner(ctx context.Context, kind, ownerID string) (bool, error) { g2 := d.sessionFactory.New(ctx) query := "SELECT EXISTS(SELECT 1 FROM resources WHERE kind = ? AND owner_id = ? AND deleted_time IS NULL" @@ -184,6 +216,15 @@ func (d *sqlResourceDao) FindByKindAndOwnerForUpdate( return resources, nil } +// FindChildrenForUpdate locks all owned rows in deterministic ID order. +func (d *sqlResourceDao) FindChildrenForUpdate(ctx context.Context, ownerID string) (api.ResourceList, error) { + var resources api.ResourceList + err := tenant.ScopeDB(d.sessionFactory.New(ctx), ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Where("owner_id = ?", ownerID).Order("id").Find(&resources).Error + return resources, err +} + func (d *sqlResourceDao) ReplaceReferences( ctx context.Context, sourceID string, refs []api.ResourceReference, ) error { @@ -237,6 +278,56 @@ func (d *sqlResourceDao) ClearTargetReferences(ctx context.Context, targetID str return nil } +// FindExternalReferenceCounts locks surviving sources, then counts their +// remaining references per source and type. The count is unscoped: inbound +// target references can originate in any tenant. +func (d *sqlResourceDao) FindExternalReferenceCounts( + ctx context.Context, targetIDs, sourceIDs []string, +) ([]ExternalReferenceCount, error) { + if len(targetIDs) == 0 { + return nil, nil + } + lockQuery := d.sessionFactory.New(ctx).Model(&api.Resource{}). + Select("id"). + Where("id IN (SELECT source_id FROM resource_references WHERE target_id IN ?)", targetIDs) + if len(sourceIDs) > 0 { + lockQuery = lockQuery.Where("id NOT IN ?", sourceIDs) + } + var lockedSources []api.Resource + if err := lockQuery.Order("id").Clauses(clause.Locking{Strength: "UPDATE"}). + Find(&lockedSources).Error; err != nil { + return nil, err + } + if len(lockedSources) == 0 { + return nil, nil + } + + g2 := d.sessionFactory.New(ctx).Table("resource_references AS refs"). + Select(`resources.kind AS source_kind, refs.ref_type, + COUNT(*) FILTER (WHERE refs.target_id NOT IN ?) AS remaining`, targetIDs). + Joins("JOIN resources ON refs.source_id = resources.id"). + Where(`EXISTS (SELECT 1 FROM resource_references AS affected + WHERE affected.source_id = refs.source_id AND affected.ref_type = refs.ref_type + AND affected.target_id IN ?)`, targetIDs) + if len(sourceIDs) > 0 { + g2 = g2.Where("refs.source_id NOT IN ?", sourceIDs) + } + var counts []ExternalReferenceCount + if err := g2.Group("refs.source_id, resources.kind, refs.ref_type"). + Scan(&counts).Error; err != nil { + return nil, err + } + return counts, nil +} + +func (d *sqlResourceDao) DeleteReferencesByTargets(ctx context.Context, targetIDs []string) error { + if len(targetIDs) == 0 { + return nil + } + return d.sessionFactory.New(ctx).Where("target_id IN ?", targetIDs). + Delete(&api.ResourceReference{}).Error +} + func (d *sqlResourceDao) FindSourceIDsByRef( ctx context.Context, refType, targetID string, ) ([]string, error) { diff --git a/pkg/services/resource.go b/pkg/services/resource.go index e6f2694b..356f09ce 100644 --- a/pkg/services/resource.go +++ b/pkg/services/resource.go @@ -3,6 +3,7 @@ package services import ( "context" "encoding/json" + stderrors "errors" "fmt" "log/slog" "strings" @@ -979,6 +980,9 @@ func applyResourcePatch(resource *api.Resource, patch *api.ResourcePatch) error return nil } +// ForceDelete hard-deletes a resource tree stuck in the Finalizing state, +// bypassing adapter finalization but still refusing to drop references required +// by surviving resources. Deadlocks return 409 so callers can retry the request. func (s *sqlResourceService) ForceDelete(ctx context.Context, kind, id, reason string) *errors.ServiceError { trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_id", id)) if svcErr := rejectSystemIdentityWrite(ctx); svcErr != nil { @@ -988,72 +992,188 @@ func (s *sqlResourceService) ForceDelete(ctx context.Context, kind, id, reason s return svcErr } trace.SpanFromContext(ctx).SetAttributes(attribute.String("hyperfleet.resource_type", registry.MustGet(kind).Plural)) - err := s.txRunner.Do(ctx, func(ctx context.Context) error { - resource, err := s.resourceDao.GetForUpdate(ctx, kind, id) - if err != nil { - return handleGetError(kind, id, err) - } - if resource.DeletedTime == nil { - return errors.ConflictState("%s '%s' is not in Finalizing state", kind, id) - } - caller := actorFromContext(ctx) - if svcErr := s.forceDeleteResourceTree(ctx, resource, caller, reason); svcErr != nil { + var deleted api.ResourceList + err := s.txRunner.Do(ctx, func(txCtx context.Context) error { + resources, svcErr := s.forceDeleteTx(txCtx, kind, id, reason) + if svcErr != nil { return svcErr } + deleted = resources return nil }) - return serviceErrorFromTransaction(err) + if err != nil { + resourceCtx := hfl.WithResourceID(hfl.WithResourceType(ctx, kind), id) + slog.ErrorContext(resourceCtx, "Force-delete failed", + "caller", actorFromContext(ctx), "reason", reason, "error", err) + if svcErr := forceDeleteTransactionConflict(err); svcErr != nil { + return svcErr + } + return serviceErrorFromTransaction(err) + } + for _, item := range deleted { + resourceCtx := hfl.WithResourceID(hfl.WithResourceType(ctx, item.Kind), item.ID) + slog.InfoContext(resourceCtx, "Force-deleted resource", "caller", actorFromContext(ctx), "reason", reason) + } + return nil } -func (s *sqlResourceService) forceDeleteResourceTree( - ctx context.Context, resource *api.Resource, caller, reason string, -) *errors.ServiceError { - resourceCtx := hfl.WithResourceType(ctx, resource.Kind) - resourceCtx = hfl.WithResourceID(resourceCtx, resource.ID) - children := registry.ChildrenOf(resource.Kind) +// forceDeleteTx locks the root, requires the Finalizing state, collects and locks +// the scoped tree, enforces remaining reference minimums, then hard-deletes the +// tree. It runs inside the caller's transaction and maps DAO failures to terminal +// ServiceErrors. The FOR UPDATE lock on surviving sources can rarely deadlock when +// two cyclically-referencing resources are force-deleted at once; the victim +// transaction rolls back and the deadlock surfaces as a 409 conflict. +func (s *sqlResourceService) forceDeleteTx( + ctx context.Context, kind, id, reason string, +) (api.ResourceList, *errors.ServiceError) { + resource, err := s.resourceDao.GetRowForUpdate(ctx, kind, id) + if err != nil { + if svcErr := forceDeleteTransactionConflict(err); svcErr != nil { + return nil, svcErr + } + return nil, handleGetError(kind, id, err) + } + if resource.DeletedTime == nil { + return nil, errors.ConflictState("%s '%s' is not in Finalizing state", kind, id) + } - childIDs := make([]string, 0) - for _, child := range children { - items, err := s.resourceDao.FindByKindAndOwnerForUpdate(resourceCtx, child.Kind, resource.ID) - if err != nil { - slog.ErrorContext(resourceCtx, "Failed to find children for force-delete", "child_kind", child.Kind, "error", err) - return errors.GeneralError("Unable to find %s children for force-delete", child.Kind) + resources, err := s.collectForceDeletionResources(ctx, resource) + if err != nil { + if svcErr := forceDeleteTransactionConflict(err); svcErr != nil { + return nil, svcErr } - for _, item := range items { - childIDs = append(childIDs, item.ID) - if svcErr := s.forceDeleteResourceTree(ctx, item, caller, reason); svcErr != nil { - return svcErr - } + return nil, errors.GeneralError("force-delete failed: %s", err) + } + ids := make([]string, len(resources)) + for i, item := range resources { + ids[i] = item.ID + } + + counts, err := s.resourceDao.FindExternalReferenceCounts(ctx, ids, ids) + if err != nil { + if svcErr := forceDeleteTransactionConflict(err); svcErr != nil { + return nil, svcErr } + return nil, errors.GeneralError("force-delete failed: %s", err) + } + if svcErr := validateRemainingReferenceMins(kind, resource.Name, counts); svcErr != nil { + return nil, svcErr } - slog.InfoContext(resourceCtx, - "Force-deleting resource", - "caller", caller, - "reason", reason, - "child_resource_ids", childIDs, - ) - if err := s.adapterStatusDao.DeleteByResource(resourceCtx, resource.Kind, resource.ID); err != nil { - return errors.GeneralError("Failed to delete adapter statuses during force-delete: %s", err) + // Record intent outside the database transaction's rollback semantics, before + // the first destructive statement. This is not a claim of successful deletion. + type auditResource struct { + Kind string `json:"kind"` + ID string `json:"id"` } - if err := s.resourceConditionDao.DeleteByResource(resourceCtx, resource.ID); err != nil { - return errors.GeneralError("Failed to delete resource conditions during force-delete: %s", err) + subresources := make([]auditResource, 0, len(resources)-1) + for _, item := range resources[1:] { + subresources = append(subresources, auditResource{Kind: item.Kind, ID: item.ID}) } - // Clear inbound references before hard-deleting (FK uses ON DELETE RESTRICT). - // Note: referencing resources with Min>0 on this ref type will silently - // violate their required-reference invariant after this operation. - if err := s.resourceDao.ClearTargetReferences(resourceCtx, resource.ID); err != nil { - return errors.GeneralError("failed to clear references: %s", err) + resourceCtx := hfl.WithResourceID(hfl.WithResourceType(ctx, kind), id) + slog.InfoContext(resourceCtx, "Force-deleting resource", + "caller", actorFromContext(ctx), "reason", reason, "subresources", subresources) + + if err := s.deleteResourceRows(ctx, ids); err != nil { + if svcErr := forceDeleteTransactionConflict(err); svcErr != nil { + return nil, svcErr + } + return nil, handleDeleteError(kind, err) } - slog.InfoContext(resourceCtx, "Cleared inbound references for force-delete") - if err := s.resourceDao.Delete(resourceCtx, resource.Kind, resource.ID); err != nil { - return handleDeleteError(resource.Kind, err) + return resources, nil +} + +func forceDeleteTransactionConflict(err error) *errors.ServiceError { + if sqlErr, ok := stderrors.AsType[interface { + error + SQLState() string + }](err); ok { + if sqlErr.SQLState() == "40P01" { + return errors.ConflictState("force-delete conflicted with a concurrent transaction; retry the request") + } + } + return nil +} + +// deleteResourceRows removes a collected force-delete tree in FK-safe order: +// inbound references first, then adapter statuses, then the resource rows. It +// runs in the caller's transaction. Resource rows stay tenant-scoped via +// DeleteIDs; the reference and adapter-status deletes are keyed by the already +// tenant-scoped, locked IDs. The calls live in the service rather than in a +// single resource-DAO method because adapter statuses are owned by +// AdapterStatusDao, which the resource DAO does not delete from. +func (s *sqlResourceService) deleteResourceRows(ctx context.Context, ids []string) error { + if err := s.resourceDao.DeleteReferencesByTargets(ctx, ids); err != nil { + return err + } + if err := s.adapterStatusDao.DeleteByResourceIDs(ctx, ids); err != nil { + return err + } + return s.resourceDao.DeleteIDs(ctx, ids) +} + +// collectForceDeletionResources locks the complete scoped tree parent-first. +func (s *sqlResourceService) collectForceDeletionResources( + ctx context.Context, root *api.Resource, +) (api.ResourceList, error) { + resources := api.ResourceList{root} + var collect func(*api.Resource) error + collect = func(parent *api.Resource) error { + children, err := s.resourceDao.FindChildrenForUpdate(ctx, parent.ID) + if err != nil { + return err + } + for _, child := range children { + resources = append(resources, child) + if err := collect(child); err != nil { + return err + } + } + return nil + } + if err := collect(root); err != nil { + return nil, err } + return resources, nil +} +func validateRemainingReferenceMins( + kind, name string, counts []dao.ExternalReferenceCount, +) *errors.ServiceError { + for _, count := range counts { + desc, registered := registry.Get(count.SourceKind) + if !registered { + return errors.GeneralError("unregistered reference source kind %s", count.SourceKind) + } + ref, ok := findReferenceDescriptor(desc.References, count.RefType) + if !ok { + return errors.GeneralError( + "unregistered reference type %s for %s", count.RefType, count.SourceKind, + ) + } + if count.Remaining < int64(ref.Min) { + return errors.ConflictState( + "cannot delete %s %q: required references would be removed", kind, name, + ) + } + } return nil } +// findReferenceDescriptor returns the descriptor for refType, or false if the +// entity does not declare that reference type. +func findReferenceDescriptor( + refs []registry.ReferenceDescriptor, refType string, +) (registry.ReferenceDescriptor, bool) { + for _, ref := range refs { + if ref.RefType == refType { + return ref, true + } + } + return registry.ReferenceDescriptor{}, false +} + // validateReferences checks that refs satisfies the ReferenceDescriptors on the entity: // - required ref types (Min > 0) must be present // - unknown ref types are rejected diff --git a/pkg/services/resource_logging_test.go b/pkg/services/resource_logging_test.go index 2b5c50c9..2fbf4e15 100644 --- a/pkg/services/resource_logging_test.go +++ b/pkg/services/resource_logging_test.go @@ -2,9 +2,11 @@ package services import ( "bytes" + "context" "encoding/json" "fmt" "log/slog" + "strings" "sync" "testing" "time" @@ -12,6 +14,7 @@ import ( hfl "github.com/openshift-hyperfleet/hyperfleet-logger" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/auth" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" ) @@ -26,6 +29,87 @@ func captureServiceLogs(t *testing.T, level slog.Level) *bytes.Buffer { return &output } +type auditResourceDao struct { + *mockResourceDao + beforeDelete func() +} + +func (d *auditResourceDao) DeleteReferencesByTargets(ctx context.Context, ids []string) error { + d.beforeDelete() + return d.mockResourceDao.DeleteReferencesByTargets(ctx, ids) +} + +func TestForceDelete_IntentLoggedBeforeDelete(t *testing.T) { + setupTestDescriptors() + output := captureServiceLogs(t, slog.LevelInfo) + resources := newMockResourceDao() + root := testResource("Channel", testChannelID, "stable") + root.DeletedTime = new(time.Now()) + resources.addResource(root) + child := testResource("Version", "v-1", "child") + child.OwnerID = &root.ID + resources.addResource(child) + resourceDao := &auditResourceDao{mockResourceDao: resources} + resourceDao.beforeDelete = func() { + var intent map[string]any + if err := json.Unmarshal(bytes.TrimSpace(output.Bytes()), &intent); err != nil { + t.Fatalf("intent must be logged before first delete: %v (%s)", err, output.String()) + } + if intent["message"] != "Force-deleting resource" || intent["caller"] != "admin@test.com" || + intent["reason"] != "stuck" || intent["resource_id"] != root.ID || intent["resource_type"] != "Channel" { + t.Fatalf("intent missing root or attribution: %v", intent) + } + subresources, ok := intent["subresources"].([]any) + if !ok || len(subresources) != 1 { + t.Fatalf("intent missing child: %v", intent) + } + loggedChild, ok := subresources[0].(map[string]any) + if !ok || loggedChild["id"] != child.ID || loggedChild["kind"] != child.Kind { + t.Fatalf("intent missing child: %v", intent) + } + } + svc, err := NewResourceService( + resourceDao, newMockResourceLabelDao(), newMockAdapterStatusDao(), newResourceConditionMock(), + &resourceGenericMock{}, &controlledTxRunner{}, + ) + if err != nil { + t.Fatal(err) + } + if svcErr := svc.ForceDelete(auth.SetUsernameContext(t.Context(), "admin@test.com"), + "Channel", root.ID, "stuck"); svcErr != nil { + t.Fatal(svcErr) + } + if got := strings.Count(output.String(), `"message":"Force-deleted resource"`); got != 2 { + t.Fatalf("got %d success logs after deletion, want 2: %s", got, output.String()) + } +} + +func TestForceDelete_CommitFailureDoesNotLogSuccess(t *testing.T) { + setupTestDescriptors() + output := captureServiceLogs(t, slog.LevelInfo) + resourceDao := newMockResourceDao() + resource := testResource("Channel", testChannelID, "stable") + resource.DeletedTime = new(time.Now()) + resourceDao.addResource(resource) + runner := &controlledTxRunner{afterCallbackErr: fmt.Errorf("db: commit transaction: %w", fmt.Errorf("commit failed"))} + svc, err := NewResourceService( + resourceDao, newMockResourceLabelDao(), newMockAdapterStatusDao(), + newResourceConditionMock(), &resourceGenericMock{}, runner, + ) + if err != nil { + t.Fatal(err) + } + if svcErr := svc.ForceDelete(t.Context(), "Channel", testChannelID, "stuck"); svcErr == nil { + t.Fatal("expected completion failure") + } + logs := output.String() + if !strings.Contains(logs, `"message":"Force-deleting resource"`) || + !strings.Contains(logs, `"message":"Force-delete failed"`) || + !strings.Contains(logs, "commit failed") || strings.Contains(logs, `"message":"Force-deleted resource"`) { + t.Fatalf("expected intent and commit failure, not success: %s", logs) + } +} + func TestProcessAdapterStatus_ConcurrentReportsRetainLogCorrelation(t *testing.T) { setupAdapterStatusDescriptors() mockDao := newMockResourceDao() diff --git a/pkg/services/resource_test.go b/pkg/services/resource_test.go index 20ed2d4a..cd3bffde 100644 --- a/pkg/services/resource_test.go +++ b/pkg/services/resource_test.go @@ -4,10 +4,12 @@ import ( "context" "encoding/json" "fmt" + "net/http" "strings" "testing" "time" + "github.com/jackc/pgx/v5/pgconn" . "github.com/onsi/gomega" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" @@ -49,15 +51,22 @@ func setupTestDescriptors() { // mockResourceDao implements dao.ResourceDao for testing. type mockResourceDao struct { - resources map[string]*api.Resource - createErr error - saveErr error - deleteErr error - existsSoftDeletedByOwnerErr error - replaceRefsErr error - findReferencersResult []api.ResourceSummary - lastReplacedRefs []api.ResourceReference - replaceRefsCalled bool + findChildrenErrorOwnerID string + createErr error + saveErr error + deleteErr error + findExternalReferenceCountsErr error + getRowForUpdateErr error + findChildrenForUpdateErr error + deleteReferencesErr error + existsSoftDeletedByOwnerErr error + replaceRefsErr error + resources map[string]*api.Resource + findReferencersResult []api.ResourceSummary + lastReplacedRefs []api.ResourceReference + deleteReferencesCalls int + deleteIDsCalls int + replaceRefsCalled bool } func newMockResourceDao() *mockResourceDao { @@ -77,6 +86,13 @@ func (d *mockResourceDao) GetForUpdate(ctx context.Context, kind, id string) (*a return d.Get(ctx, kind, id) } +func (d *mockResourceDao) GetRowForUpdate(ctx context.Context, kind, id string) (*api.Resource, error) { + if d.getRowForUpdateErr != nil { + return nil, d.getRowForUpdateErr + } + return d.Get(ctx, kind, id) +} + func (d *mockResourceDao) GetByOwner(_ context.Context, kind, id, ownerID string) (*api.Resource, error) { r, ok := d.resources[resourceKey(kind, id)] if !ok { @@ -112,6 +128,21 @@ func (d *mockResourceDao) Delete(_ context.Context, kind, id string) error { return nil } +func (d *mockResourceDao) DeleteIDs(ctx context.Context, ids []string) error { + d.deleteIDsCalls++ + for _, id := range ids { + for _, r := range d.resources { + if r.ID == id { + if err := d.Delete(ctx, r.Kind, id); err != nil { + return err + } + break + } + } + } + return nil +} + func (d *mockResourceDao) ExistsByOwner(_ context.Context, kind, ownerID string) (bool, error) { for _, r := range d.resources { if r.Kind == kind && r.OwnerID != nil && *r.OwnerID == ownerID && r.DeletedTime == nil { @@ -166,6 +197,19 @@ func (d *mockResourceDao) FindByKindAndOwnerForUpdate( return d.FindByKindAndOwner(ctx, kind, ownerID) } +func (d *mockResourceDao) FindChildrenForUpdate(_ context.Context, ownerID string) (api.ResourceList, error) { + if d.findChildrenForUpdateErr != nil && ownerID == d.findChildrenErrorOwnerID { + return nil, d.findChildrenForUpdateErr + } + var result api.ResourceList + for _, r := range d.resources { + if r.OwnerID != nil && *r.OwnerID == ownerID { + result = append(result, r) + } + } + return result, nil +} + func (d *mockResourceDao) GetByID(_ context.Context, id string) (*api.Resource, error) { for _, r := range d.resources { if r.ID == id { @@ -189,6 +233,17 @@ func (d *mockResourceDao) FindByIDs(_ context.Context, kind string, ids []string return result, nil } +func (d *mockResourceDao) FindExternalReferenceCounts( + _ context.Context, _, _ []string, +) ([]dao.ExternalReferenceCount, error) { + return nil, d.findExternalReferenceCountsErr +} + +func (d *mockResourceDao) DeleteReferencesByTargets(_ context.Context, _ []string) error { + d.deleteReferencesCalls++ + return d.deleteReferencesErr +} + func (d *mockResourceDao) ReplaceReferences(_ context.Context, _ string, refs []api.ResourceReference) error { d.replaceRefsCalled = true d.lastReplacedRefs = refs @@ -1705,6 +1760,127 @@ func TestResourceService_Delete_CascadeParentSoftDeletedWhileChildSoftDeleted(t // --- ForceDelete --- +func TestResourceService_ForceDelete_ReferenceCountErrors(t *testing.T) { + // The API does not request an isolation level that produces serialization failures. + // Keep 40001 on the existing fallback error path. + for _, tc := range []struct { + name string + err error + code string + status int + }{ + {"serialization", &pgconn.PgError{Code: "40001"}, errors.CodeInternalGeneral, http.StatusInternalServerError}, + {"deadlock", &pgconn.PgError{Code: "40P01"}, errors.CodeConflictState, http.StatusConflict}, + { + "wrapped serialization", fmt.Errorf("query: %w", &pgconn.PgError{Code: "40001"}), + errors.CodeInternalGeneral, http.StatusInternalServerError, + }, + { + "wrapped deadlock", fmt.Errorf("query: %w", &pgconn.PgError{Code: "40P01"}), + errors.CodeConflictState, http.StatusConflict, + }, + {"other SQL error", &pgconn.PgError{Code: "XX000"}, errors.CodeInternalGeneral, http.StatusInternalServerError}, + {"other error", fmt.Errorf("query failed"), errors.CodeInternalGeneral, http.StatusInternalServerError}, + } { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + setupTestDescriptors() + mockDao := newMockResourceDao() + mockDao.findExternalReferenceCountsErr = tc.err + svc, _, _ := newTestResourceService(mockDao) + resource := testResource("Channel", testChannelID, "stable") + resource.DeletedTime = new(time.Now()) + mockDao.addResource(resource) + + svcErr := svc.ForceDelete(t.Context(), "Channel", testChannelID, "stuck") + g.Expect(svcErr).NotTo(BeNil()) + g.Expect(svcErr.RFC9457Code).To(Equal(tc.code)) + g.Expect(svcErr.HTTPCode).To(Equal(tc.status)) + g.Expect(mockDao.resources).To(HaveKey(resourceKey("Channel", testChannelID))) + }) + } +} + +func TestResourceService_ForceDelete_DatabaseErrors(t *testing.T) { + const recursiveChildLookup = "recursive child lookup" + for _, stage := range []string{ + "root lookup", "child lookup", recursiveChildLookup, "reference counts", + "reference deletion", "adapter status deletion", "resource deletion", "transaction completion", + } { + for _, tc := range []struct { + name string + err error + code string + status int + }{ + {"serialization", &pgconn.PgError{Code: "40001"}, errors.CodeInternalGeneral, http.StatusInternalServerError}, + { + "wrapped deadlock", fmt.Errorf("database: %w", &pgconn.PgError{Code: "40P01"}), + errors.CodeConflictState, http.StatusConflict, + }, + {"other SQL error", &pgconn.PgError{Code: "XX000"}, errors.CodeInternalGeneral, http.StatusInternalServerError}, + {"other error", fmt.Errorf("database failed"), errors.CodeInternalGeneral, http.StatusInternalServerError}, + } { + t.Run(stage+"/"+tc.name, func(t *testing.T) { + g := NewWithT(t) + setupTestDescriptors() + mockDao := newMockResourceDao() + adapterDao := newMockAdapterStatusDao() + runner := &controlledTxRunner{} + resource := testResource("Channel", testChannelID, "stable") + resource.DeletedTime = new(time.Now()) + mockDao.addResource(resource) + if stage == recursiveChildLookup { + child := testResource("Version", "v-1", "child") + child.OwnerID = &resource.ID + mockDao.addResource(child) + } + switch stage { + case "root lookup": + mockDao.getRowForUpdateErr = tc.err + case "child lookup": + mockDao.findChildrenErrorOwnerID = resource.ID + mockDao.findChildrenForUpdateErr = tc.err + case recursiveChildLookup: + mockDao.findChildrenErrorOwnerID = "v-1" + mockDao.findChildrenForUpdateErr = tc.err + case "reference counts": + mockDao.findExternalReferenceCountsErr = tc.err + case "reference deletion": + mockDao.deleteReferencesErr = tc.err + case "adapter status deletion": + adapterDao.deleteByResourceErr = tc.err + case "resource deletion": + mockDao.deleteErr = tc.err + case "transaction completion": + runner.afterCallbackErr = fmt.Errorf("db: commit transaction: %w", tc.err) + } + svc, err := NewResourceService( + mockDao, newMockResourceLabelDao(), adapterDao, newResourceConditionMock(), + &resourceGenericMock{}, runner, + ) + g.Expect(err).NotTo(HaveOccurred()) + svcErr := svc.ForceDelete(t.Context(), "Channel", testChannelID, "stuck") + g.Expect(svcErr).NotTo(BeNil()) + g.Expect(svcErr.RFC9457Code).To(Equal(tc.code)) + g.Expect(svcErr.HTTPCode).To(Equal(tc.status)) + g.Expect(runner.calls).To(Equal(1)) + // Later deletion steps must not run after an earlier failure. + if stage == "root lookup" || stage == "child lookup" || stage == recursiveChildLookup || + stage == "reference counts" { + g.Expect(mockDao.deleteReferencesCalls).To(Equal(0)) + } + if stage == "reference deletion" { + g.Expect(adapterDao.deleteByResourceIDsCalls).To(Equal(0)) + } + if stage != "resource deletion" && stage != "transaction completion" { + g.Expect(mockDao.deleteIDsCalls).To(Equal(0)) + } + }) + } + } +} + func TestResourceService_ForceDelete_HappyPath_NoChildren(t *testing.T) { RegisterTestingT(t) setupTestDescriptors() diff --git a/pkg/services/test_helpers_test.go b/pkg/services/test_helpers_test.go index 43e8a410..b2f11628 100644 --- a/pkg/services/test_helpers_test.go +++ b/pkg/services/test_helpers_test.go @@ -25,9 +25,10 @@ func (r *controlledTxRunner) Do(ctx context.Context, callback func(context.Conte } type mockAdapterStatusDao struct { - statuses map[string]*api.AdapterStatus - findByResourceErr error - deleteByResourceErr error + statuses map[string]*api.AdapterStatus + findByResourceErr error + deleteByResourceErr error + deleteByResourceIDsCalls int } func newMockAdapterStatusDao() *mockAdapterStatusDao { @@ -89,6 +90,22 @@ func (d *mockAdapterStatusDao) DeleteByResource(ctx context.Context, resourceTyp return nil } +func (d *mockAdapterStatusDao) DeleteByResourceIDs(_ context.Context, ids []string) error { + d.deleteByResourceIDsCalls++ + if d.deleteByResourceErr != nil { + return d.deleteByResourceErr + } + for key, status := range d.statuses { + for _, id := range ids { + if status.ResourceID == id { + delete(d.statuses, key) + break + } + } + } + return d.deleteByResourceErr +} + func (d *mockAdapterStatusDao) FindByResource( ctx context.Context, resourceType, resourceID string, diff --git a/test/integration/resource_references_test.go b/test/integration/resource_references_test.go index 97fc856f..dae128fb 100644 --- a/test/integration/resource_references_test.go +++ b/test/integration/resource_references_test.go @@ -11,6 +11,7 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/registry" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" @@ -44,6 +45,34 @@ func registerRefTestDescriptors() { {RefType: "link", TargetKind: "RefTarget", Min: 0, Max: 0}, }, }) + registry.Register(registry.EntityDescriptor{ + Kind: "MultiSource", + Plural: "multisources", + References: []registry.ReferenceDescriptor{ + {RefType: "dep", TargetKind: "RefTarget", Min: 1, Max: 0}, + }, + }) + registry.Register(registry.EntityDescriptor{Kind: "RefTree", Plural: "reftrees"}) + registry.Register(registry.EntityDescriptor{ + Kind: "RefChild", + Plural: "refchildren", + ParentKind: "RefTree", + OnParentDelete: registry.OnParentDeleteCascade, + References: []registry.ReferenceDescriptor{ + {RefType: "peer", TargetKind: "RefChild", Min: 0, Max: 1}, + }, + }) + registry.Register(registry.EntityDescriptor{ + Kind: "RefGrandchild", Plural: "refgrandchildren", ParentKind: "RefChild", + OnParentDelete: registry.OnParentDeleteCascade, + }) + registry.Register(registry.EntityDescriptor{ + Kind: "RefChildSource", + Plural: "refchildsources", + References: []registry.ReferenceDescriptor{ + {RefType: "child", TargetKind: "RefChild", Min: 0, Max: 1}, + }, + }) }) } @@ -63,6 +92,13 @@ func newRefTestResource(kind, name string) *api.Resource { } } +func newOwnedRefTestResource(kind, name, ownerKind, ownerID string) *api.Resource { + resource := newRefTestResource(kind, name) + resource.OwnerID = util.ToPtr(ownerID) + resource.OwnerKind = util.ToPtr(ownerKind) + return resource +} + // makeRefs builds a reference map with a single ref type and target(s). func makeRefs(refType string, targets ...struct{ id, kind string }) api.ReferenceMap { refs := make([]openapi.ObjectReference, len(targets)) @@ -342,7 +378,7 @@ func TestResourceReferences_Delete(t *testing.T) { Expect(dbErr).To(BeNil(), "target should be hard-deleted from DB") }) - t.Run("ForceDeleteReferencedTarget_Succeeds", func(t *testing.T) { + t.Run("ForceDeleteReferencedRequiredTarget_Returns409", func(t *testing.T) { RegisterTestingT(t) svc, h := setupRefTest(t) @@ -351,7 +387,7 @@ func TestResourceReferences_Delete(t *testing.T) { Expect(svcErr).To(BeNil()) refs := makeRefs(p.refType, struct{ id, kind string }{target.ID, p.target}) - _, svcErr = svc.Create(t.Context(), p.source, + source, svcErr := svc.Create(t.Context(), p.source, newRefTestResource(p.source, suffix("source-fd")), refs) Expect(svcErr).To(BeNil()) @@ -362,16 +398,238 @@ func TestResourceReferences_Delete(t *testing.T) { markFinalizing(t, h, target.ID) svcErr = svc.ForceDelete(t.Context(), p.target, target.ID, "force delete target") - Expect(svcErr).To(BeNil(), "force-delete should bypass reference restriction") + Expect(svcErr).NotTo(BeNil(), "force-delete should reject deleting a required reference") + Expect(svcErr.HTTPCode).To(Equal(409)) - _, getErr := svc.Get(t.Context(), p.target, target.ID) - Expect(getErr).ToNot(BeNil()) - Expect(getErr.HTTPCode).To(Equal(404), "target should be gone after force-delete") + retainedTarget, getErr := svc.Get(t.Context(), p.target, target.ID) + Expect(getErr).To(BeNil(), "target should remain after rejected force-delete") + Expect(retainedTarget.DeletedTime).NotTo(BeNil(), "target should remain finalizing") + + source, getErr = svc.Get(t.Context(), p.source, source.ID) + Expect(getErr).To(BeNil()) + Expect(source.References).To(HaveLen(1), "required reference should remain intact") + Expect(source.References[0].TargetID).To(Equal(target.ID)) + Expect(source.References[0].RefType).To(Equal(p.refType)) }) }) } } +func TestResourceReferences_ForceDeleteOptionalExternalReferenceSucceeds(t *testing.T) { + RegisterTestingT(t) + svc, h := setupRefTest(t) + + target, svcErr := svc.Create(t.Context(), "RefTarget", + newRefTestResource("RefTarget", fmt.Sprintf("target-opt-delete-%s", uuid.NewString()[:8])), nil) + Expect(svcErr).To(BeNil()) + refs := makeRefs("link", struct{ id, kind string }{target.ID, "RefTarget"}) + source, svcErr := svc.Create(t.Context(), "OptSource", + newRefTestResource("OptSource", fmt.Sprintf("source-opt-delete-%s", uuid.NewString()[:8])), refs) + Expect(svcErr).To(BeNil()) + + _, svcErr = svc.Delete(t.Context(), "RefTarget", target.ID) + Expect(svcErr).NotTo(BeNil()) + Expect(svcErr.HTTPCode).To(Equal(409)) + + markFinalizing(t, h, target.ID) + svcErr = svc.ForceDelete(t.Context(), "RefTarget", target.ID, "verify optional reference boundary") + Expect(svcErr).To(BeNil(), "force-delete should allow clearing an optional reference") + _, getErr := svc.Get(t.Context(), "RefTarget", target.ID) + Expect(getErr).NotTo(BeNil()) + Expect(getErr.HTTPCode).To(Equal(404)) + + retrieved, getErr := svc.Get(t.Context(), "OptSource", source.ID) + Expect(getErr).To(BeNil()) + Expect(retrieved.References).To(BeEmpty(), "force-delete should clear the optional reference") +} + +func TestResourceReferences_ForceDeleteRetainsEnoughRequiredReferences(t *testing.T) { + RegisterTestingT(t) + svc, h := setupRefTest(t) + + target1, svcErr := svc.Create(t.Context(), "RefTarget", + newRefTestResource("RefTarget", fmt.Sprintf("target-multi-one-%s", uuid.NewString()[:8])), nil) + Expect(svcErr).To(BeNil()) + target2, svcErr := svc.Create(t.Context(), "RefTarget", + newRefTestResource("RefTarget", fmt.Sprintf("target-multi-two-%s", uuid.NewString()[:8])), nil) + Expect(svcErr).To(BeNil()) + source, svcErr := svc.Create(t.Context(), "MultiSource", + newRefTestResource("MultiSource", fmt.Sprintf("source-multi-%s", uuid.NewString()[:8])), + makeRefs("dep", + struct{ id, kind string }{target1.ID, "RefTarget"}, + struct{ id, kind string }{target2.ID, "RefTarget"})) + Expect(svcErr).To(BeNil()) + + _, svcErr = svc.Delete(t.Context(), "RefTarget", target1.ID) + Expect(svcErr).NotTo(BeNil()) + Expect(svcErr.HTTPCode).To(Equal(409)) + markFinalizing(t, h, target1.ID) + Expect(svc.ForceDelete(t.Context(), "RefTarget", target1.ID, "retain another required reference")).To(BeNil()) + + _, getErr := svc.Get(t.Context(), "RefTarget", target1.ID) + Expect(getErr).NotTo(BeNil()) + Expect(getErr.HTTPCode).To(Equal(404)) + retrieved, getErr := svc.Get(t.Context(), "MultiSource", source.ID) + Expect(getErr).To(BeNil()) + Expect(retrieved.References).To(HaveLen(1)) + Expect(retrieved.References[0].TargetID).To(Equal(target2.ID)) + Expect(retrieved.References[0].RefType).To(Equal("dep")) +} + +func TestResourceReferences_InternalSiblingReferencesDeleteWithTree(t *testing.T) { + RegisterTestingT(t) + svc, h := setupRefTest(t) + + root, svcErr := svc.Create(t.Context(), "RefTree", + newRefTestResource("RefTree", fmt.Sprintf("tree-internal-%s", uuid.NewString()[:8])), nil) + Expect(svcErr).To(BeNil()) + child1, svcErr := svc.Create(t.Context(), "RefChild", + newOwnedRefTestResource("RefChild", fmt.Sprintf("child-one-%s", uuid.NewString()[:8]), "RefTree", root.ID), nil) + Expect(svcErr).To(BeNil()) + child2Refs := makeRefs("peer", struct{ id, kind string }{child1.ID, "RefChild"}) + child2, svcErr := svc.Create(t.Context(), "RefChild", + newOwnedRefTestResource("RefChild", fmt.Sprintf("child-two-%s", uuid.NewString()[:8]), "RefTree", root.ID), + child2Refs) + Expect(svcErr).To(BeNil()) + _, svcErr = svc.Patch(t.Context(), "RefChild", child1.ID, &api.ResourcePatch{ + References: makeRefs("peer", struct{ id, kind string }{child2.ID, "RefChild"}), + }) + Expect(svcErr).To(BeNil()) + grandchildResource := newOwnedRefTestResource("RefGrandchild", + fmt.Sprintf("grandchild-%s", uuid.NewString()[:8]), "RefChild", child1.ID) + grandchild, svcErr := svc.Create(t.Context(), "RefGrandchild", grandchildResource, nil) + Expect(svcErr).To(BeNil()) + + markFinalizing(t, h, root.ID) + Expect(svc.ForceDelete(t.Context(), "RefTree", root.ID, "remove internally referenced tree")).To(BeNil()) + Expect(checkResourceCount(t.Context(), h, []string{root.ID, child1.ID, child2.ID, grandchild.ID}, 0)).To(Succeed()) + + var referenceCount int64 + Expect(h.DBFactory.New(t.Context()).Table("resource_references"). + Where("source_id IN ?", []string{child1.ID, child2.ID}). + Count(&referenceCount).Error).To(Succeed()) + Expect(referenceCount).To(BeZero()) +} + +func TestResourceReferences_ForceDeleteClearsOptionalDescendantReference(t *testing.T) { + RegisterTestingT(t) + svc, h := setupRefTest(t) + + root, svcErr := svc.Create(t.Context(), "RefTree", + newRefTestResource("RefTree", fmt.Sprintf("tree-external-%s", uuid.NewString()[:8])), nil) + Expect(svcErr).To(BeNil()) + child, svcErr := svc.Create(t.Context(), "RefChild", + newOwnedRefTestResource("RefChild", fmt.Sprintf("child-external-%s", uuid.NewString()[:8]), "RefTree", root.ID), nil) + Expect(svcErr).To(BeNil()) + sourceRefs := makeRefs("child", struct{ id, kind string }{child.ID, "RefChild"}) + source, svcErr := svc.Create(t.Context(), "RefChildSource", + newRefTestResource("RefChildSource", fmt.Sprintf("source-external-%s", uuid.NewString()[:8])), sourceRefs) + Expect(svcErr).To(BeNil()) + + _, svcErr = svc.Delete(t.Context(), "RefTree", root.ID) + Expect(svcErr).NotTo(BeNil()) + Expect(svcErr.HTTPCode).To(Equal(409)) + activeRoot, getErr := svc.Get(t.Context(), "RefTree", root.ID) + Expect(getErr).To(BeNil()) + Expect(activeRoot.DeletedTime).To(BeNil(), "normal deletion must roll back the entire tree") + + markFinalizing(t, h, root.ID) + svcErr = svc.ForceDelete(t.Context(), "RefTree", root.ID, "clear optional descendant reference") + Expect(svcErr).To(BeNil()) + Expect(checkResourceCount(t.Context(), h, []string{root.ID, child.ID}, 0)).To(Succeed()) + retrievedSource, getErr := svc.Get(t.Context(), "RefChildSource", source.ID) + Expect(getErr).To(BeNil()) + Expect(retrievedSource.References).To(BeEmpty()) +} + +func TestResourceReferences_ForceDeleteClearsOutboundReferenceToExternalTarget(t *testing.T) { + RegisterTestingT(t) + svc, h := setupRefTest(t) + + extRoot, svcErr := svc.Create(t.Context(), "RefTree", + newRefTestResource("RefTree", fmt.Sprintf("tree-ext-survivor-%s", uuid.NewString()[:8])), nil) + Expect(svcErr).To(BeNil()) + extChild, svcErr := svc.Create(t.Context(), "RefChild", + newOwnedRefTestResource("RefChild", + fmt.Sprintf("child-ext-survivor-%s", uuid.NewString()[:8]), "RefTree", extRoot.ID), nil) + Expect(svcErr).To(BeNil()) + + root, svcErr := svc.Create(t.Context(), "RefTree", + newRefTestResource("RefTree", fmt.Sprintf("tree-outbound-%s", uuid.NewString()[:8])), nil) + Expect(svcErr).To(BeNil()) + childRefs := makeRefs("peer", struct{ id, kind string }{extChild.ID, "RefChild"}) + child, svcErr := svc.Create(t.Context(), "RefChild", + newOwnedRefTestResource("RefChild", + fmt.Sprintf("child-outbound-%s", uuid.NewString()[:8]), "RefTree", root.ID), + childRefs) + Expect(svcErr).To(BeNil()) + + markFinalizing(t, h, root.ID) + svcErr = svc.ForceDelete(t.Context(), "RefTree", root.ID, "clear outbound reference to external target") + Expect(svcErr).To(BeNil()) + + Expect(checkResourceCount(t.Context(), h, []string{root.ID, child.ID}, 0)).To(Succeed()) + + var refCount int64 + Expect(h.DBFactory.New(t.Context()).Table("resource_references"). + Where("source_id = ?", child.ID). + Count(&refCount).Error).To(Succeed()) + Expect(refCount).To(BeZero(), "outbound reference row should be cascade-deleted with its source") + + Expect(checkResourceCount(t.Context(), h, []string{extRoot.ID, extChild.ID}, 2)).To(Succeed()) + survivor, getErr := svc.Get(t.Context(), "RefChild", extChild.ID) + Expect(getErr).To(BeNil(), "external target must survive the tree deletion") + Expect(survivor.DeletedTime).To(BeNil()) +} + +func TestResourceReferences_ConcurrentForceDeletesPreserveMin(t *testing.T) { + RegisterTestingT(t) + svc, h := setupRefTest(t) + first, svcErr := svc.Create(t.Context(), "RefTarget", + newRefTestResource("RefTarget", fmt.Sprintf("race-first-%s", uuid.NewString()[:8])), nil) + Expect(svcErr).To(BeNil()) + second, svcErr := svc.Create(t.Context(), "RefTarget", + newRefTestResource("RefTarget", fmt.Sprintf("race-second-%s", uuid.NewString()[:8])), nil) + Expect(svcErr).To(BeNil()) + source, svcErr := svc.Create(t.Context(), "MultiSource", + newRefTestResource("MultiSource", fmt.Sprintf("race-source-%s", uuid.NewString()[:8])), + makeRefs("dep", struct{ id, kind string }{first.ID, "RefTarget"}, + struct{ id, kind string }{second.ID, "RefTarget"})) + Expect(svcErr).To(BeNil()) + markFinalizing(t, h, first.ID) + markFinalizing(t, h, second.ID) + + results := make(chan *errors.ServiceError, 2) + ready := make(chan struct{}, 2) + for _, id := range []string{first.ID, second.ID} { + go func(id string) { + ready <- struct{}{} + results <- svc.ForceDelete(t.Context(), "RefTarget", id, "concurrent delete") + }(id) + } + for range 2 { + <-ready + } + + var successes, conflicts int + for range 2 { + result := <-results + switch { + case result == nil: + successes++ + case result.HTTPCode == 409: + conflicts++ + default: + t.Fatalf("unexpected concurrent deletion error: %v", result) + } + } + Expect(successes).To(Equal(1)) + Expect(conflicts).To(Equal(1)) + retrieved, getErr := svc.Get(t.Context(), "MultiSource", source.ID) + Expect(getErr).To(BeNil()) + Expect(retrieved.References).To(HaveLen(1)) +} + // --- List with ref_type filter --- func TestResourceReferences_ListByRefTypeAndTarget(t *testing.T) { From ce40df69d8a4f5e3b8e001622e5d4fe5b84bf97b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michaela=20Pa=C5=99ilov=C3=A1?= Date: Fri, 25 Sep 2026 11:42:57 +0200 Subject: [PATCH 13/41] HYPERFLEET-1500 - fix: run condition mapper without required adapters --- pkg/services/resource.go | 9 +++-- pkg/services/resource_test.go | 75 +++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/pkg/services/resource.go b/pkg/services/resource.go index 356f09ce..1a938346 100644 --- a/pkg/services/resource.go +++ b/pkg/services/resource.go @@ -185,10 +185,11 @@ func (s *sqlResourceService) Create( created.References = refRows } - // Initialize conditions for entities with required adapters, matching the - // old ClusterService/NodePoolService behavior. + // Initialize conditions for entities with required adapters (matching the + // old ClusterService/NodePoolService behavior) or a configured CEL condition + // mapper desc := registry.MustGet(kind) - if len(desc.RequiredAdapters) > 0 { + if len(desc.RequiredAdapters) > 0 || s.conditionMappers[kind] != nil { recomputed, svcErr := s.recomputeAndSaveResourceConditions(ctx, created, nil) if svcErr != nil { return svcErr @@ -274,7 +275,7 @@ func (s *sqlResourceService) Patch( // Recompute conditions after generation change. desc := registry.MustGet(kind) - if len(desc.RequiredAdapters) > 0 { + if len(desc.RequiredAdapters) > 0 || s.conditionMappers[kind] != nil { adapterStatuses, statusErr := s.adapterStatusDao.FindByResource(ctx, kind, resource.ID) if statusErr != nil { return errors.GeneralError("failed to get adapter statuses for condition recompute: %s", statusErr) diff --git a/pkg/services/resource_test.go b/pkg/services/resource_test.go index cd3bffde..f57a8004 100644 --- a/pkg/services/resource_test.go +++ b/pkg/services/resource_test.go @@ -3577,6 +3577,81 @@ func TestResourceService_Patch_SkipsConditionsWithoutRequiredAdapters(t *testing "Patch should not seed conditions for entities without RequiredAdapters") } +// conditionMapperRuleForSpecKey tracks resource.spec.key == "value", flippable via patch +func conditionMapperRuleForSpecKey() registry.ConditionMappingRule { + return registry.ConditionMappingRule{ + Type: "TierReady", + When: registry.MappingExpression{Expression: `true`}, + Output: registry.MappingOutput{ + Status: registry.MappingExpression{Expression: `resource.spec.key == "value" ? "True" : "False"`}, + Reason: registry.MappingExpression{Expression: `"TierEvaluated"`}, + Message: registry.MappingExpression{Expression: `"tier evaluated from spec.key"`}, + }, + } +} + +func TestResourceService_Create_RunsConditionMapper_WithoutRequiredAdapters(t *testing.T) { + RegisterTestingT(t) + registry.Reset() + t.Cleanup(registry.Reset) + registry.Register(registry.EntityDescriptor{ + Kind: "Channel", + Plural: "channels", + Conditions: []registry.ConditionMappingRule{conditionMapperRuleForSpecKey()}, + }) + + mockDao := newMockResourceDao() + svc, _, _, rcDao := newTestResourceServiceWithConditions(mockDao) + + resource := testResource("Channel", "", "stable") + result, svcErr := svc.Create(context.Background(), "Channel", resource, nil) + Expect(svcErr).To(BeNil()) + + conditions := rcDao.conditions[result.ID] + Expect(conditions).ToNot(BeEmpty(), + "Conditions should be seeded on Create when a condition mapper is configured, even with no RequiredAdapters") + + mapped := findCondition(conditions, "TierReady") + Expect(mapped).ToNot(BeNil(), "mapped condition should be produced from the CEL rule") + Expect(mapped.Status).To(Equal(api.ConditionTrue)) + + recon := findCondition(conditions, api.ResourceConditionTypeReconciled) + Expect(recon).ToNot(BeNil()) + Expect(recon.Status).To(Equal(api.ConditionFalse), + "Reconciled is always False for entities with zero RequiredAdapters") +} + +func TestResourceService_Patch_RunsConditionMapper_WithoutRequiredAdapters(t *testing.T) { + RegisterTestingT(t) + registry.Reset() + t.Cleanup(registry.Reset) + registry.Register(registry.EntityDescriptor{ + Kind: "Channel", + Plural: "channels", + Conditions: []registry.ConditionMappingRule{conditionMapperRuleForSpecKey()}, + }) + + mockDao := newMockResourceDao() + svc, _, _, rcDao := newTestResourceServiceWithConditions(mockDao) + + resource := testResource("Channel", "ch-1", "stable") + created, svcErr := svc.Create(context.Background(), "Channel", resource, nil) + Expect(svcErr).To(BeNil()) + + mapped := findCondition(rcDao.conditions[created.ID], "TierReady") + Expect(mapped).ToNot(BeNil()) + Expect(mapped.Status).To(Equal(api.ConditionTrue)) + + patch := &api.ResourcePatch{Spec: map[string]any{"key": "other"}} + _, svcErr = svc.Patch(context.Background(), "Channel", "ch-1", patch) + Expect(svcErr).To(BeNil()) + + mappedAfter := findCondition(rcDao.conditions[created.ID], "TierReady") + Expect(mappedAfter).ToNot(BeNil()) + Expect(mappedAfter.Status).To(Equal(api.ConditionFalse), + "Patch should recompute mapped conditions for entities without RequiredAdapters when a mapper is configured") +} + func TestProcessAdapterStatus_FinalizedTrue_RecomputesConditions_WhenHardDeleteBlocked(t *testing.T) { RegisterTestingT(t) registry.Reset() From a3a57a651d356ad884c4723177d861191de5af80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michaela=20Pa=C5=99ilov=C3=A1?= Date: Fri, 25 Sep 2026 12:23:41 +0200 Subject: [PATCH 14/41] HYPERFLEET-1715 - fix: set GOFIPS140=off for boringcrypto builds --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 152a9b95..b96e093a 100755 --- a/Makefile +++ b/Makefile @@ -156,11 +156,11 @@ generate-vendor: generate build: generate-all ## Build the hyperfleet-api binary @mkdir -p bin @echo "Building version: ${APP_VERSION}" - CGO_ENABLED=$(CGO_ENABLED) GOEXPERIMENT=boringcrypto ${GO} build $(GOFLAGS) -ldflags="$(LDFLAGS)" -o bin/hyperfleet-api ./cmd/hyperfleet-api + CGO_ENABLED=$(CGO_ENABLED) GOEXPERIMENT=boringcrypto GOFIPS140=off ${GO} build $(GOFLAGS) -ldflags="$(LDFLAGS)" -o bin/hyperfleet-api ./cmd/hyperfleet-api .PHONY: install install: generate-all ## Build and install binary to GOPATH/bin - CGO_ENABLED=$(CGO_ENABLED) GOEXPERIMENT=boringcrypto ${GO} install $(GOFLAGS) -ldflags="$(LDFLAGS)" ./cmd/hyperfleet-api + CGO_ENABLED=$(CGO_ENABLED) GOEXPERIMENT=boringcrypto GOFIPS140=off ${GO} install $(GOFLAGS) -ldflags="$(LDFLAGS)" ./cmd/hyperfleet-api # Common CLI flags for local database access DB_FLAGS = --db-host localhost --db-port $(db_port) --db-name $(db_name) \ @@ -194,7 +194,7 @@ run/docs: check-container-tool ## Run swagger and host the api spec cmds: ## Build all binaries under cmd/ @mkdir -p bin for cmd in $$(ls cmd); do \ - CGO_ENABLED=$(CGO_ENABLED) GOEXPERIMENT=boringcrypto ${GO} build \ + CGO_ENABLED=$(CGO_ENABLED) GOEXPERIMENT=boringcrypto GOFIPS140=off ${GO} build \ $(GOFLAGS) \ -ldflags="$(LDFLAGS)" \ -o "bin/$${cmd}" \ From 70229727141fc9a3fa8a461d75613a1a574a3e64 Mon Sep 17 00:00:00 2001 From: "red-hat-konflux-kflux-prd-rh02[bot]" <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:03:11 +0000 Subject: [PATCH 15/41] Update Konflux references 0.x Signed-off-by: red-hat-konflux-kflux-prd-rh02 <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> --- .tekton/hyperfleet-api-chart-push.yaml | 2 +- .tekton/hyperfleet-api-chart-tag.yaml | 2 +- .tekton/hyperfleet-api-push.yaml | 16 ++++++++-------- .tekton/hyperfleet-api-tag.yaml | 16 ++++++++-------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.tekton/hyperfleet-api-chart-push.yaml b/.tekton/hyperfleet-api-chart-push.yaml index 87239a39..80196985 100644 --- a/.tekton/hyperfleet-api-chart-push.yaml +++ b/.tekton/hyperfleet-api-chart-push.yaml @@ -97,7 +97,7 @@ spec: - name: name value: init - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:c6c414a3b5dcd827720f3443a128a02d27a0a5071ea3e4dc0063d578f1f73a74 + value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:7854d7babe4136e53e337f6433d952532314433b48bb62b422ddc02700934d85 - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-chart-tag.yaml b/.tekton/hyperfleet-api-chart-tag.yaml index cde2578c..9a4996eb 100644 --- a/.tekton/hyperfleet-api-chart-tag.yaml +++ b/.tekton/hyperfleet-api-chart-tag.yaml @@ -103,7 +103,7 @@ spec: - name: name value: init - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:c6c414a3b5dcd827720f3443a128a02d27a0a5071ea3e4dc0063d578f1f73a74 + value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:7854d7babe4136e53e337f6433d952532314433b48bb62b422ddc02700934d85 - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-push.yaml b/.tekton/hyperfleet-api-push.yaml index 94a6e7bb..48bdfe10 100644 --- a/.tekton/hyperfleet-api-push.yaml +++ b/.tekton/hyperfleet-api-push.yaml @@ -146,7 +146,7 @@ spec: - name: name value: init - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:c6c414a3b5dcd827720f3443a128a02d27a0a5071ea3e4dc0063d578f1f73a74 + value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:7854d7babe4136e53e337f6433d952532314433b48bb62b422ddc02700934d85 - name: kind value: task resolver: bundles @@ -251,7 +251,7 @@ spec: - name: name value: buildah-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.12.1@sha256:9eef3bf4c8a6a28605c713d56048fef5349bc4c791f289d8b95396ab039eda6b + value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.12.2@sha256:e1bbe61a3a4bc543be9b5f6fc4174acaa2dd08fb5f7fe4d9db9f3193a7e1b03c - name: kind value: task resolver: bundles @@ -273,7 +273,7 @@ spec: - name: name value: build-image-index - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.3.1@sha256:1e2e00be81beae3645588957a8c29645660d2614db1063cfded85509bf9124fb + value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.4@sha256:654446d8ec028b20ed6ada8a96486ff16692a25192755aa04a99cea8e0afb7e2 - name: kind value: task resolver: bundles @@ -294,7 +294,7 @@ spec: - name: name value: source-build-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.3.1@sha256:bbad5bfd67d1263320a0c4d3f062a18ae611e081b009fd96117c2a00a6ce8dd9 + value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.3.2@sha256:45091b5022754d452b4de60c7d7f4c6e03626efff55f96e9af937400fe3506eb - name: kind value: task resolver: bundles @@ -338,7 +338,7 @@ spec: - name: name value: roxctl-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-roxctl-scan:0.1@sha256:97e2b2cdca9110fdc8a93ba585a1a1a743f989f2fd85f4073f6e5ea9ad2ce828 + value: quay.io/konflux-ci/tekton-catalog/task-roxctl-scan:0.1@sha256:4d48db2ada6584bc7343ee11adb04d86f1ca9a2849dc718ccda861e092fcf41b - name: kind value: task resolver: bundles @@ -408,7 +408,7 @@ spec: - name: name value: clamav-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3.3@sha256:9df272d2180bd67e6a3e04d5cd30ffa7ba27af4e599a1922f7069f5c89888e5b + value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3.4@sha256:3203cd3bde82d9e4d1beb5f1366942346800cff89e392c920733eb0ea78d731b - name: kind value: task resolver: bundles @@ -509,7 +509,7 @@ spec: - name: name value: push-dockerfile-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile-oci-ta:0.3.1@sha256:3e59d6303ca031c27fab079f7c743fbdecc53d007b8d86e32227de5164f06e31 + value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile-oci-ta:0.3.1@sha256:4c567d11296850230fe32e76d129834d3bf337a0cae9989b64279b7cfaa4e735 - name: kind value: task resolver: bundles @@ -526,7 +526,7 @@ spec: - name: name value: rpms-signature-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:d9df5d325f81a9fa132cb597c465aa9210b9b0fa3716b85b32be98d92c9c881a + value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:4b1856e41d087e53af26562fb16a490579777d9ff05af33011ce212d477c7cd5 - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-tag.yaml b/.tekton/hyperfleet-api-tag.yaml index 065c768b..46ade20a 100644 --- a/.tekton/hyperfleet-api-tag.yaml +++ b/.tekton/hyperfleet-api-tag.yaml @@ -153,7 +153,7 @@ spec: - name: name value: init - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:c6c414a3b5dcd827720f3443a128a02d27a0a5071ea3e4dc0063d578f1f73a74 + value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:7854d7babe4136e53e337f6433d952532314433b48bb62b422ddc02700934d85 - name: kind value: task resolver: bundles @@ -281,7 +281,7 @@ spec: - name: name value: buildah-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.12.1@sha256:9eef3bf4c8a6a28605c713d56048fef5349bc4c791f289d8b95396ab039eda6b + value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.12.2@sha256:e1bbe61a3a4bc543be9b5f6fc4174acaa2dd08fb5f7fe4d9db9f3193a7e1b03c - name: kind value: task resolver: bundles @@ -303,7 +303,7 @@ spec: - name: name value: build-image-index - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.3.1@sha256:1e2e00be81beae3645588957a8c29645660d2614db1063cfded85509bf9124fb + value: quay.io/konflux-ci/tekton-catalog/task-build-image-index:0.4@sha256:654446d8ec028b20ed6ada8a96486ff16692a25192755aa04a99cea8e0afb7e2 - name: kind value: task resolver: bundles @@ -324,7 +324,7 @@ spec: - name: name value: source-build-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.3.1@sha256:bbad5bfd67d1263320a0c4d3f062a18ae611e081b009fd96117c2a00a6ce8dd9 + value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.3.2@sha256:45091b5022754d452b4de60c7d7f4c6e03626efff55f96e9af937400fe3506eb - name: kind value: task resolver: bundles @@ -368,7 +368,7 @@ spec: - name: name value: roxctl-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-roxctl-scan:0.1@sha256:97e2b2cdca9110fdc8a93ba585a1a1a743f989f2fd85f4073f6e5ea9ad2ce828 + value: quay.io/konflux-ci/tekton-catalog/task-roxctl-scan:0.1@sha256:4d48db2ada6584bc7343ee11adb04d86f1ca9a2849dc718ccda861e092fcf41b - name: kind value: task resolver: bundles @@ -438,7 +438,7 @@ spec: - name: name value: clamav-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3.3@sha256:9df272d2180bd67e6a3e04d5cd30ffa7ba27af4e599a1922f7069f5c89888e5b + value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3.4@sha256:3203cd3bde82d9e4d1beb5f1366942346800cff89e392c920733eb0ea78d731b - name: kind value: task resolver: bundles @@ -539,7 +539,7 @@ spec: - name: name value: push-dockerfile-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile-oci-ta:0.3.1@sha256:3e59d6303ca031c27fab079f7c743fbdecc53d007b8d86e32227de5164f06e31 + value: quay.io/konflux-ci/tekton-catalog/task-push-dockerfile-oci-ta:0.3.1@sha256:4c567d11296850230fe32e76d129834d3bf337a0cae9989b64279b7cfaa4e735 - name: kind value: task resolver: bundles @@ -556,7 +556,7 @@ spec: - name: name value: rpms-signature-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:d9df5d325f81a9fa132cb597c465aa9210b9b0fa3716b85b32be98d92c9c881a + value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:4b1856e41d087e53af26562fb16a490579777d9ff05af33011ce212d477c7cd5 - name: kind value: task resolver: bundles From 4f161ccbdfe6c73b1c3ce7fd250e3061401156ef Mon Sep 17 00:00:00 2001 From: "red-hat-konflux-kflux-prd-rh02[bot]" <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:06:02 +0000 Subject: [PATCH 16/41] Update module github.com/go-playground/validator/v10 to v10.30.5 Signed-off-by: red-hat-konflux-kflux-prd-rh02 <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> --- go.mod | 16 ++++++++-------- go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/go.mod b/go.mod index 6e5363c1..683ef0be 100755 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/brianvoe/gofakeit/v7 v7.16.0 github.com/getkin/kin-openapi v0.149.0 github.com/go-gormigrate/gormigrate/v2 v2.1.6 - github.com/go-playground/validator/v10 v10.30.3 + github.com/go-playground/validator/v10 v10.30.5 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/jinzhu/inflection v1.0.0 @@ -78,7 +78,7 @@ require ( github.com/ebitengine/purego v0.10.1 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gabriel-vasile/mimetype v1.4.15 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect @@ -92,11 +92,11 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.10.0 // indirect + github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.19.1 // indirect - github.com/leodido/go-urn v1.4.0 // indirect + github.com/leodido/go-urn v1.5.0 // indirect github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -131,11 +131,11 @@ require ( go.opentelemetry.io/otel/metric v1.46.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.55.0 // indirect + golang.org/x/crypto v0.57.0 // indirect golang.org/x/net v0.58.0 // indirect - golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect google.golang.org/grpc v1.83.1 // indirect diff --git a/go.sum b/go.sum index 407d8615..c4a6819d 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= -github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= -github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= github.com/getkin/kin-openapi v0.149.0 h1:ZbhmVJ4yq5RZDUsyP8lcBcGMsjsaTqXEFt6isdtMDfA= github.com/getkin/kin-openapi v0.149.0/go.mod h1:1+BHDzstro+P5CKtPy1X4PfofnFgmRe6uvMy9+r9fKY= github.com/go-gormigrate/gormigrate/v2 v2.1.6 h1:VtX+l1Stj2v5RGubVQk0LS/8EPGXR+ldcOyCmlmKoyg= @@ -88,8 +88,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= -github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4= +github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ= github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw= github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -136,8 +136,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= -github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= +github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 h1:YkjVPl/YH5XlJ+/NiwzJtPYXXKRcyjmEUhsDci6YK3c= @@ -294,29 +294,29 @@ go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= From 9ea2824b753352a68c3ec09e6768f74c46b728d6 Mon Sep 17 00:00:00 2001 From: "red-hat-konflux-kflux-prd-rh02[bot]" <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:06:15 +0000 Subject: [PATCH 17/41] Update registry.access.redhat.com/ubi9/go-toolset Docker tag to v9.8-1790174511 Signed-off-by: red-hat-konflux-kflux-prd-rh02 <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index d3ee18f0..93313e6f 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ ARG BASE_IMAGE=registry.access.redhat.com/ubi9-micro:latest -FROM registry.access.redhat.com/ubi9/go-toolset:9.8-1789040808 AS builder +FROM registry.access.redhat.com/ubi9/go-toolset:9.8-1790174511 AS builder ARG GIT_SHA=unknown ARG GIT_DIRTY="" From d809700cf2153b4cc784f92b19d046c8648b26e0 Mon Sep 17 00:00:00 2001 From: Dmitrii Andreev Date: Thu, 1 Oct 2026 19:17:41 -0500 Subject: [PATCH 18/41] HYPERFLEET-1652 - feat: Alert the team in Slack when a Konflux build pipeline fails --- .tekton/README.md | 52 ++++++++++++++++++++++++++ .tekton/hyperfleet-api-chart-push.yaml | 27 +++++++++++++ .tekton/hyperfleet-api-chart-tag.yaml | 27 +++++++++++++ .tekton/hyperfleet-api-push.yaml | 27 +++++++++++++ .tekton/hyperfleet-api-tag.yaml | 27 +++++++++++++ 5 files changed, 160 insertions(+) create mode 100644 .tekton/README.md diff --git a/.tekton/README.md b/.tekton/README.md new file mode 100644 index 00000000..350c3140 --- /dev/null +++ b/.tekton/README.md @@ -0,0 +1,52 @@ +# Konflux build failure alerts + +The four PipelineRuns in this directory build the API image and chart: + +| PipelineRun | Trigger | +| --- | --- | +| `hyperfleet-api-on-push` | Push to `main` | +| `hyperfleet-api-on-tag` | Version tag | +| `hyperfleet-api-chart-on-push` | Chart push | +| `hyperfleet-api-chart-on-tag` | Chart tag | + +Each inline `pipelineSpec` has a `slack-webhook-notification` task in `finally`. +It runs only when `$(tasks.status)` is `Failed`. The Slack message links the +repository, commit, and failed PipelineRun with readable text. A successful +PipelineRun skips this task. A failure before a PipelineRun starts, including a +Pipelines as Code admission failure, cannot trigger it. + +The task reads key `hyperfleet-slack-webhook-url` from Secret +`hyperfleet-slack-webhook-notification-secret` in `hyperfleet-tenant` on +`kflux-prd-rh02`. The webhook value must never be committed or printed in logs. +The Secret must be available to the build task Pod. HyperFleet manages this +Secret in its own tenant namespace. The webhook determines the Slack channel. +The existing release notification uses a separate Secret in +`rhtap-releng-tenant`, managed by RelEng. + +## Check a missing alert + +Open the failed PipelineRun in the [Konflux UI](https://konflux-ui.apps.kflux-prd-rh02.0fk9.p1.openshiftapps.com/) +and inspect its `slack-webhook-notification` final task. Check whether it was +scheduled, whether the Secret and key were mounted, and whether the Slack POST +succeeded. If no PipelineRun exists, inspect the Pipelines as Code check on the +GitHub commit. A successful run should show the final task skipped and produce +no build failure alert. + +## Rotate the webhook + +1. Obtain a replacement incoming webhook for the agreed team channel through + the HyperFleet team. Confirm whether the URL is shared with release + notifications. +2. Update `hyperfleet-tenant/hyperfleet-slack-webhook-notification-secret`, key + `hyperfleet-slack-webhook-url`, through the team's Secret management process. + Update its configuration source if one is used. If the URL is shared, + coordinate the release namespace Secret update with RelEng. +3. With approval for a controlled Konflux run and Slack post, trigger a failed + PipelineRun and confirm the alert fields, link, and delivery time. Check a + successful run emits no build failure alert. If sharing the URL, confirm a + release notification still arrives. +4. Revoke the old webhook only after every consumer has been verified on the + replacement URL. + +See the [HyperFleet notification runbook](https://github.com/openshift-hyperfleet/architecture/blob/main/hyperfleet/docs/release/operations/notifications.md) +for build and release notification ownership and troubleshooting. diff --git a/.tekton/hyperfleet-api-chart-push.yaml b/.tekton/hyperfleet-api-chart-push.yaml index 80196985..b5310248 100644 --- a/.tekton/hyperfleet-api-chart-push.yaml +++ b/.tekton/hyperfleet-api-chart-push.yaml @@ -230,6 +230,33 @@ spec: operator: in values: - "false" + finally: + - name: slack-webhook-notification + params: + - name: message + value: | + :rotating_light: *Konflux build failed* + *Repository:* <$(params.git-url)|hyperfleet-api> + *Pipeline:* `hyperfleet-api-chart-on-push` + *Commit:* + *Failed run:* + - name: secret-name + value: hyperfleet-slack-webhook-notification-secret + - name: key-name + value: hyperfleet-slack-webhook-url + taskRef: + resolver: bundles + params: + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-slack-webhook-notification:0.1@sha256:55fdfbe3b4a565aa58fc76d6209a3b69832f43373ee6e33555bb69423a8b9ceb + - name: name + value: slack-webhook-notification + - name: kind + value: Task + when: + - input: $(tasks.status) + operator: in + values: ["Failed"] workspaces: - name: git-auth optional: true diff --git a/.tekton/hyperfleet-api-chart-tag.yaml b/.tekton/hyperfleet-api-chart-tag.yaml index 9a4996eb..70b71237 100644 --- a/.tekton/hyperfleet-api-chart-tag.yaml +++ b/.tekton/hyperfleet-api-chart-tag.yaml @@ -263,6 +263,33 @@ spec: operator: in values: - "false" + finally: + - name: slack-webhook-notification + params: + - name: message + value: | + :rotating_light: *Konflux build failed* + *Repository:* <$(params.git-url)|hyperfleet-api> + *Pipeline:* `hyperfleet-api-chart-on-tag` + *Commit:* + *Failed run:* + - name: secret-name + value: hyperfleet-slack-webhook-notification-secret + - name: key-name + value: hyperfleet-slack-webhook-url + taskRef: + resolver: bundles + params: + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-slack-webhook-notification:0.1@sha256:55fdfbe3b4a565aa58fc76d6209a3b69832f43373ee6e33555bb69423a8b9ceb + - name: name + value: slack-webhook-notification + - name: kind + value: Task + when: + - input: $(tasks.status) + operator: in + values: ["Failed"] workspaces: - name: git-auth optional: true diff --git a/.tekton/hyperfleet-api-push.yaml b/.tekton/hyperfleet-api-push.yaml index 48bdfe10..e0f80e3a 100644 --- a/.tekton/hyperfleet-api-push.yaml +++ b/.tekton/hyperfleet-api-push.yaml @@ -535,6 +535,33 @@ spec: operator: in values: - "false" + finally: + - name: slack-webhook-notification + params: + - name: message + value: | + :rotating_light: *Konflux build failed* + *Repository:* <$(params.git-url)|hyperfleet-api> + *Pipeline:* `hyperfleet-api-on-push` + *Commit:* + *Failed run:* + - name: secret-name + value: hyperfleet-slack-webhook-notification-secret + - name: key-name + value: hyperfleet-slack-webhook-url + taskRef: + resolver: bundles + params: + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-slack-webhook-notification:0.1@sha256:55fdfbe3b4a565aa58fc76d6209a3b69832f43373ee6e33555bb69423a8b9ceb + - name: name + value: slack-webhook-notification + - name: kind + value: Task + when: + - input: $(tasks.status) + operator: in + values: ["Failed"] workspaces: - name: git-auth optional: true diff --git a/.tekton/hyperfleet-api-tag.yaml b/.tekton/hyperfleet-api-tag.yaml index 46ade20a..499fb071 100644 --- a/.tekton/hyperfleet-api-tag.yaml +++ b/.tekton/hyperfleet-api-tag.yaml @@ -565,6 +565,33 @@ spec: operator: in values: - "false" + finally: + - name: slack-webhook-notification + params: + - name: message + value: | + :rotating_light: *Konflux build failed* + *Repository:* <$(params.git-url)|hyperfleet-api> + *Pipeline:* `hyperfleet-api-on-tag` + *Commit:* + *Failed run:* + - name: secret-name + value: hyperfleet-slack-webhook-notification-secret + - name: key-name + value: hyperfleet-slack-webhook-url + taskRef: + resolver: bundles + params: + - name: bundle + value: quay.io/konflux-ci/tekton-catalog/task-slack-webhook-notification:0.1@sha256:55fdfbe3b4a565aa58fc76d6209a3b69832f43373ee6e33555bb69423a8b9ceb + - name: name + value: slack-webhook-notification + - name: kind + value: Task + when: + - input: $(tasks.status) + operator: in + values: ["Failed"] workspaces: - name: git-auth optional: true From da95ca0c6cb28fe3bce4119b48122c7b79880d28 Mon Sep 17 00:00:00 2001 From: "red-hat-konflux-kflux-prd-rh02[bot]" <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:06:46 +0000 Subject: [PATCH 19/41] Update Konflux references digest Signed-off-by: red-hat-konflux-kflux-prd-rh02 <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> --- .tekton/hyperfleet-api-chart-push.yaml | 2 +- .tekton/hyperfleet-api-chart-tag.yaml | 2 +- .tekton/hyperfleet-api-push.yaml | 10 +++++----- .tekton/hyperfleet-api-tag.yaml | 10 +++++----- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.tekton/hyperfleet-api-chart-push.yaml b/.tekton/hyperfleet-api-chart-push.yaml index b5310248..bc16af35 100644 --- a/.tekton/hyperfleet-api-chart-push.yaml +++ b/.tekton/hyperfleet-api-chart-push.yaml @@ -170,7 +170,7 @@ spec: - name: name value: build-helm-chart-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.4.0@sha256:7c7c7117e59e1c8d96a9ebc63807b5d4950d30ebabcd127eec4950a89d99eece + value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.4.0@sha256:1cfcc0de161da38b5dd4b3b806a291688bcb50ffd41285f2ef72a772ce238896 - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-chart-tag.yaml b/.tekton/hyperfleet-api-chart-tag.yaml index 70b71237..176e74b7 100644 --- a/.tekton/hyperfleet-api-chart-tag.yaml +++ b/.tekton/hyperfleet-api-chart-tag.yaml @@ -203,7 +203,7 @@ spec: - name: name value: build-helm-chart-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.4.0@sha256:7c7c7117e59e1c8d96a9ebc63807b5d4950d30ebabcd127eec4950a89d99eece + value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.4.0@sha256:1cfcc0de161da38b5dd4b3b806a291688bcb50ffd41285f2ef72a772ce238896 - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-push.yaml b/.tekton/hyperfleet-api-push.yaml index e0f80e3a..de5f37fb 100644 --- a/.tekton/hyperfleet-api-push.yaml +++ b/.tekton/hyperfleet-api-push.yaml @@ -251,7 +251,7 @@ spec: - name: name value: buildah-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.12.2@sha256:e1bbe61a3a4bc543be9b5f6fc4174acaa2dd08fb5f7fe4d9db9f3193a7e1b03c + value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.12.3@sha256:3c57ec441d23c81d73df3b1f95cd5e800d53674fa0e8af784d81402e087e30bf - name: kind value: task resolver: bundles @@ -338,7 +338,7 @@ spec: - name: name value: roxctl-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-roxctl-scan:0.1@sha256:4d48db2ada6584bc7343ee11adb04d86f1ca9a2849dc718ccda861e092fcf41b + value: quay.io/konflux-ci/tekton-catalog/task-roxctl-scan:0.1@sha256:8286d4dd5a337596ed4776d68152656abed9a464777dbb87b00256fd5987789e - name: kind value: task resolver: bundles @@ -358,7 +358,7 @@ spec: - name: name value: ecosystem-cert-preflight-checks - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:fa53ef450ba538addafee1211ff6ac15b55865ce652fec15ac99b554680cb642 + value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:439014132f4521ea3221ea29b1fd83e48f9728c071db6edaf6b870550e5d89a6 - name: kind value: task resolver: bundles @@ -408,7 +408,7 @@ spec: - name: name value: clamav-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3.4@sha256:3203cd3bde82d9e4d1beb5f1366942346800cff89e392c920733eb0ea78d731b + value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3.4@sha256:22fb08a2d08000aadf22a9cbaaa8265ba70e25fe4e24d81a3f3e64440d9e6b5e - name: kind value: task resolver: bundles @@ -526,7 +526,7 @@ spec: - name: name value: rpms-signature-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:4b1856e41d087e53af26562fb16a490579777d9ff05af33011ce212d477c7cd5 + value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:12239e624c5bca759fc594bdefd3d4411a62c69bb4c29c5ad733b1e0d88fb8e9 - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-tag.yaml b/.tekton/hyperfleet-api-tag.yaml index 499fb071..b60b7505 100644 --- a/.tekton/hyperfleet-api-tag.yaml +++ b/.tekton/hyperfleet-api-tag.yaml @@ -281,7 +281,7 @@ spec: - name: name value: buildah-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.12.2@sha256:e1bbe61a3a4bc543be9b5f6fc4174acaa2dd08fb5f7fe4d9db9f3193a7e1b03c + value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.12.3@sha256:3c57ec441d23c81d73df3b1f95cd5e800d53674fa0e8af784d81402e087e30bf - name: kind value: task resolver: bundles @@ -368,7 +368,7 @@ spec: - name: name value: roxctl-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-roxctl-scan:0.1@sha256:4d48db2ada6584bc7343ee11adb04d86f1ca9a2849dc718ccda861e092fcf41b + value: quay.io/konflux-ci/tekton-catalog/task-roxctl-scan:0.1@sha256:8286d4dd5a337596ed4776d68152656abed9a464777dbb87b00256fd5987789e - name: kind value: task resolver: bundles @@ -388,7 +388,7 @@ spec: - name: name value: ecosystem-cert-preflight-checks - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:fa53ef450ba538addafee1211ff6ac15b55865ce652fec15ac99b554680cb642 + value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:439014132f4521ea3221ea29b1fd83e48f9728c071db6edaf6b870550e5d89a6 - name: kind value: task resolver: bundles @@ -438,7 +438,7 @@ spec: - name: name value: clamav-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3.4@sha256:3203cd3bde82d9e4d1beb5f1366942346800cff89e392c920733eb0ea78d731b + value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3.4@sha256:22fb08a2d08000aadf22a9cbaaa8265ba70e25fe4e24d81a3f3e64440d9e6b5e - name: kind value: task resolver: bundles @@ -556,7 +556,7 @@ spec: - name: name value: rpms-signature-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:4b1856e41d087e53af26562fb16a490579777d9ff05af33011ce212d477c7cd5 + value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:12239e624c5bca759fc594bdefd3d4411a62c69bb4c29c5ad733b1e0d88fb8e9 - name: kind value: task resolver: bundles From e1967f98ebba370f1da6e1eaad77805f5dc6d577 Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Mon, 5 Oct 2026 14:55:01 -0300 Subject: [PATCH 20/41] HYPERFLEET-1651 - fix: re-pin Helm chart task to working bundle digest --- .tekton/hyperfleet-api-chart-push.yaml | 2 +- .tekton/hyperfleet-api-chart-tag.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.tekton/hyperfleet-api-chart-push.yaml b/.tekton/hyperfleet-api-chart-push.yaml index bc16af35..f1f07b0e 100644 --- a/.tekton/hyperfleet-api-chart-push.yaml +++ b/.tekton/hyperfleet-api-chart-push.yaml @@ -170,7 +170,7 @@ spec: - name: name value: build-helm-chart-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.4.0@sha256:1cfcc0de161da38b5dd4b3b806a291688bcb50ffd41285f2ef72a772ce238896 + value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.3@sha256:cc59ae16baa756d23f3e5f8328cd9f8ba39a388f8eafbb9db666dea694814d25 - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-chart-tag.yaml b/.tekton/hyperfleet-api-chart-tag.yaml index 176e74b7..7001111f 100644 --- a/.tekton/hyperfleet-api-chart-tag.yaml +++ b/.tekton/hyperfleet-api-chart-tag.yaml @@ -203,7 +203,7 @@ spec: - name: name value: build-helm-chart-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.4.0@sha256:1cfcc0de161da38b5dd4b3b806a291688bcb50ffd41285f2ef72a772ce238896 + value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.3@sha256:cc59ae16baa756d23f3e5f8328cd9f8ba39a388f8eafbb9db666dea694814d25 - name: kind value: task resolver: bundles From 5dbda6e808b6fd64650aaa36fcbd0f8cb2aca865 Mon Sep 17 00:00:00 2001 From: Rafael Benevides Date: Mon, 5 Oct 2026 15:20:05 -0300 Subject: [PATCH 21/41] HYPERFLEET-1377 - fix: enforce resource name database limit --- pkg/handlers/resource_handler_test.go | 27 +++++++++++++++++++++++++++ pkg/handlers/validation.go | 10 +++++----- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/pkg/handlers/resource_handler_test.go b/pkg/handlers/resource_handler_test.go index 7a66104b..18e3c79a 100644 --- a/pkg/handlers/resource_handler_test.go +++ b/pkg/handlers/resource_handler_test.go @@ -1250,6 +1250,33 @@ func TestRootResourceHandler_Create_RejectsInvalidName(t *testing.T) { } } +func TestRootResourceHandler_Create_RejectsNameExceedingDBColumnLimit(t *testing.T) { + RegisterTestingT(t) + registry.Reset() + registry.Register(registry.EntityDescriptor{ + Kind: "Channel", + Plural: "channels", + NameMinLen: 1, + NameMaxLen: 253, + }) + t.Cleanup(registry.Reset) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + handler, _, _ := newTestRootResourceHandler(ctrl) + + name := strings.Repeat("a", 150) + body := fmt.Sprintf(`{"kind":"Channel","name":%q,"spec":{}}`, name) + req := httptest.NewRequest(http.MethodPost, "/api/hyperfleet/v1/resources", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + handler.Create(rr, req) + + Expect(rr.Code).To(Equal(http.StatusBadRequest)) + Expect(rr.Body.String()).To(ContainSubstring("name must be at most 100 characters")) +} + func TestResourceHandler_Create_ChildKindWithoutParent_Returns422(t *testing.T) { RegisterTestingT(t) ctrl := gomock.NewController(t) diff --git a/pkg/handlers/validation.go b/pkg/handlers/validation.go index c7bfd1fd..2f9564d8 100755 --- a/pkg/handlers/validation.go +++ b/pkg/handlers/validation.go @@ -24,7 +24,7 @@ const ( maxObservedTimeSkew = 5 * time.Minute // tolerance for clock skew between adapter pods and API server maxObservedTimeAge = 30 * time.Minute // matches Sentinel staleness health check window // dbNameMaxLen is the hard ceiling from the resources.name column (gorm:"size:100"). - // Applied when a descriptor sets NameMaxLen = 0 (no configured limit). + // Descriptor-specific limits may be more restrictive, but cannot exceed this DB limit. dbNameMaxLen = 100 ) @@ -133,10 +133,10 @@ func validateName(i interface{}, fieldName string, field string, minLen, maxLen return errors.Validation("%s must be at least %d characters", field, minLen) } - // Check maximum length (0 = no constraint beyond DB limit) - effectiveMax := maxLen - if effectiveMax == 0 { - effectiveMax = dbNameMaxLen + // Honor a stricter descriptor limit, but never allow a name that exceeds the DB column. + effectiveMax := dbNameMaxLen + if maxLen > 0 && maxLen < effectiveMax { + effectiveMax = maxLen } if len(name) > effectiveMax { return errors.Validation("%s must be at most %d characters", field, effectiveMax) From ad4697ac41bc56255193447aebe888ca8eaad10e Mon Sep 17 00:00:00 2001 From: Rafael Benevides Date: Mon, 5 Oct 2026 16:39:44 -0300 Subject: [PATCH 22/41] HYPERFLEET-1377 - test: cover inclusive resource name limit --- pkg/handlers/resource_handler_test.go | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/pkg/handlers/resource_handler_test.go b/pkg/handlers/resource_handler_test.go index 18e3c79a..d3af51f9 100644 --- a/pkg/handlers/resource_handler_test.go +++ b/pkg/handlers/resource_handler_test.go @@ -1263,7 +1263,7 @@ func TestRootResourceHandler_Create_RejectsNameExceedingDBColumnLimit(t *testing ctrl := gomock.NewController(t) defer ctrl.Finish() - handler, _, _ := newTestRootResourceHandler(ctrl) + handler, mockSvc, _ := newTestRootResourceHandler(ctrl) name := strings.Repeat("a", 150) body := fmt.Sprintf(`{"kind":"Channel","name":%q,"spec":{}}`, name) @@ -1275,6 +1275,29 @@ func TestRootResourceHandler_Create_RejectsNameExceedingDBColumnLimit(t *testing Expect(rr.Code).To(Equal(http.StatusBadRequest)) Expect(rr.Body.String()).To(ContainSubstring("name must be at most 100 characters")) + + nameAtLimit := strings.Repeat("a", 100) + mockSvc.EXPECT().Create( + gomock.Any(), "Channel", gomock.AssignableToTypeOf(&api.Resource{}), gomock.Any(), + ).Return(&api.Resource{ + Meta: api.Meta{ID: "channel-at-limit", CreatedTime: time.Now(), UpdatedTime: time.Now()}, + Kind: "Channel", + Name: nameAtLimit, + Href: "/api/hyperfleet/v1/channels/channel-at-limit", + Spec: []byte(`{}`), + Generation: 1, + CreatedBy: "system@hyperfleet.local", + UpdatedBy: "system@hyperfleet.local", + }, nil) + + body = fmt.Sprintf(`{"kind":"Channel","name":%q,"spec":{}}`, nameAtLimit) + req = httptest.NewRequest(http.MethodPost, "/api/hyperfleet/v1/resources", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr = httptest.NewRecorder() + + handler.Create(rr, req) + + Expect(rr.Code).To(Equal(http.StatusCreated)) } func TestResourceHandler_Create_ChildKindWithoutParent_Returns422(t *testing.T) { From ba4f20282900921db504c915175c995f74c04891 Mon Sep 17 00:00:00 2001 From: Ciaran Roche Date: Tue, 6 Oct 2026 11:34:28 +0100 Subject: [PATCH 23/41] HYPERFLEET-1744 - fix: re-pin Konflux task bundles to valid digests --- .tekton/hyperfleet-api-push.yaml | 2 +- .tekton/hyperfleet-api-tag.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.tekton/hyperfleet-api-push.yaml b/.tekton/hyperfleet-api-push.yaml index de5f37fb..ffc6507f 100644 --- a/.tekton/hyperfleet-api-push.yaml +++ b/.tekton/hyperfleet-api-push.yaml @@ -526,7 +526,7 @@ spec: - name: name value: rpms-signature-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:12239e624c5bca759fc594bdefd3d4411a62c69bb4c29c5ad733b1e0d88fb8e9 + value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:cc1b939d933d24727ef4b90e1c5c65a6d2a6c1c036cf74f685b597d611fa150f - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-tag.yaml b/.tekton/hyperfleet-api-tag.yaml index b60b7505..b43e6993 100644 --- a/.tekton/hyperfleet-api-tag.yaml +++ b/.tekton/hyperfleet-api-tag.yaml @@ -556,7 +556,7 @@ spec: - name: name value: rpms-signature-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:12239e624c5bca759fc594bdefd3d4411a62c69bb4c29c5ad733b1e0d88fb8e9 + value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:cc1b939d933d24727ef4b90e1c5c65a6d2a6c1c036cf74f685b597d611fa150f - name: kind value: task resolver: bundles From f5a00b40ae7e1e8bdeec5af1fc8911fd06046dee Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Fri, 9 Oct 2026 01:41:37 -0300 Subject: [PATCH 24/41] HYPERFLEET-1679 - fix: advertise https Service/container port when API TLS is enabled --- charts/templates/_helpers.tpl | 8 ++++++++ charts/templates/deployment.yaml | 2 +- charts/templates/service.yaml | 7 +++++-- docs/deployment.md | 18 ++++++++++++++++++ scripts/test-helm.sh | 20 ++++++++++++++++++++ 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/charts/templates/_helpers.tpl b/charts/templates/_helpers.tpl index f0009601..dda4d51a 100644 --- a/charts/templates/_helpers.tpl +++ b/charts/templates/_helpers.tpl @@ -70,6 +70,14 @@ Selector labels (includes component: api) app.kubernetes.io/component: api {{- end }} +{{/* +API container/Service port name: must match the API listener's actual +protocol so Kubernetes-aware proxies and service meshes route correctly. +*/}} +{{- define "hyperfleet-api.apiPortName" -}} +{{- if .Values.config.server.tls.enabled }}https{{ else }}http{{ end }} +{{- end }} + {{/* Create the name of the service account to use */}} diff --git a/charts/templates/deployment.yaml b/charts/templates/deployment.yaml index a57c82cf..272fa1f8 100644 --- a/charts/templates/deployment.yaml +++ b/charts/templates/deployment.yaml @@ -94,7 +94,7 @@ spec: args: - serve ports: - - name: http + - name: {{ include "hyperfleet-api.apiPortName" . }} containerPort: {{ .Values.ports.api | default 8000 }} protocol: TCP - name: health diff --git a/charts/templates/service.yaml b/charts/templates/service.yaml index 59cc6100..7d1185bb 100644 --- a/charts/templates/service.yaml +++ b/charts/templates/service.yaml @@ -8,9 +8,12 @@ spec: type: {{ .Values.service.type | default "ClusterIP" }} ports: - port: {{ .Values.ports.api | default 8000 }} - targetPort: http + targetPort: {{ include "hyperfleet-api.apiPortName" . }} protocol: TCP - name: http + name: {{ include "hyperfleet-api.apiPortName" . }} + {{- if .Values.config.server.tls.enabled }} + appProtocol: https + {{- end }} - port: {{ .Values.ports.health | default 8080 }} targetPort: health protocol: TCP diff --git a/docs/deployment.md b/docs/deployment.md index b7dd3dfc..2c400ceb 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -185,6 +185,24 @@ See [Issuer configuration reference](authentication.md#issuer-configuration-refe --- +## Configuring TLS + +TLS on the API listener is **disabled by default**. Enable it with `config.server.tls.*`: + +```bash +helm install hyperfleet-api oci://quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-api-chart: \ + --namespace hyperfleet-system \ + --set config.server.tls.enabled=true \ + --set config.server.tls.cert_file=/etc/hyperfleet/tls/tls.crt \ + --set config.server.tls.key_file=/etc/hyperfleet/tls/tls.key +``` + +When `config.server.tls.enabled=true`, the chart renames the API container port and Service port from `http` to `https` and sets `appProtocol: https` on the Service, so Kubernetes-aware proxies and service meshes route to the listener as TLS instead of plaintext. **Clients must use `https://` against this Service once TLS is enabled** — a plaintext request to the `https` port fails with `Client sent an HTTP request to an HTTPS server`. + +If TLS instead terminates in front of the API — at an ingress or gateway — configure that ingress/gateway's backend for plain HTTP (matching `config.server.tls.enabled=false`), or use TLS passthrough/re-encryption so it still speaks TLS to the pod. Mixing the two (API TLS enabled with an ingress also terminating and re-encrypting as HTTP) reproduces the same protocol mismatch. + +--- + ## Configuring Tenant Enforcement Tenant enforcement scopes resource reads, lists, updates, and deletes to the caller's tenant. It is **disabled by default** and is **only safe behind the Envoy + Authorino gateway**, which injects the trusted tenant headers the API relies on. That trust holds only when a NetworkPolicy restricts API pod ingress to the Envoy pod, so no in-cluster workload can reach the API directly and forge tenant headers — see [ADR-0020](https://github.com/openshift-hyperfleet/architecture/blob/main/hyperfleet/adrs/0020-envoy-authorino-api-gateway.md). See [Tenant isolation](authentication.md#tenant-isolation) for the trust model. diff --git a/scripts/test-helm.sh b/scripts/test-helm.sh index 5b3f9603..351e0cac 100755 --- a/scripts/test-helm.sh +++ b/scripts/test-helm.sh @@ -97,6 +97,26 @@ run_test "template rendering with default values" render | kubeconform_validate pass "Default values template" +run_test "template with API TLS disabled (default): http port naming unchanged" +OUTPUT=$(render) +assert_contains "$OUTPUT" 'name: http' "expected http container/Service port name by default" +assert_contains "$OUTPUT" 'targetPort: http' "expected Service targetPort: http by default" +assert_not_contains "$OUTPUT" 'name: https' "unexpected https port name with TLS disabled" +assert_not_contains "$OUTPUT" 'appProtocol' "unexpected appProtocol with TLS disabled" +echo "$OUTPUT" | kubeconform_validate +pass "API TLS disabled port naming config template" + +run_test "template with API TLS enabled: https port naming and appProtocol" +OUTPUT=$(render --set config.server.tls.enabled=true) +assert_contains "$OUTPUT" 'name: https' "expected https container/Service port name with TLS enabled" +assert_contains "$OUTPUT" 'targetPort: https' "expected Service targetPort: https with TLS enabled" +assert_contains "$OUTPUT" 'appProtocol: https' "expected Service appProtocol: https with TLS enabled" +if echo "$OUTPUT" | grep -qE 'name: http$'; then + fail "unexpected plain http port name with TLS enabled" +fi +echo "$OUTPUT" | kubeconform_validate +pass "API TLS enabled port naming config template" + run_test "template with external database" render \ --set database.postgresql.enabled=false \ From 10a85640a0e8799469fa06c903d9ec05182e7e1d Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Fri, 9 Oct 2026 02:06:54 -0300 Subject: [PATCH 25/41] HYPERFLEET-1679 - fix: support existingConfigMap TLS and document cert mount --- charts/templates/_helpers.tpl | 21 ++++++++++++++++++++- charts/templates/service.yaml | 2 +- charts/values.yaml | 6 ++++++ docs/deployment.md | 12 ++++++++++-- scripts/test-helm.sh | 25 +++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 4 deletions(-) diff --git a/charts/templates/_helpers.tpl b/charts/templates/_helpers.tpl index dda4d51a..7e4914cd 100644 --- a/charts/templates/_helpers.tpl +++ b/charts/templates/_helpers.tpl @@ -70,12 +70,31 @@ Selector labels (includes component: api) app.kubernetes.io/component: api {{- end }} +{{/* +Whether the API listener effectively has TLS enabled. config.server.tls.enabled +is ignored when config.existingConfigMap is set (its config.yaml is opaque to +Helm), so that case requires the operator to declare +config.existingConfigMapTLSEnabled explicitly instead of silently guessing and +risking the exact protocol mismatch this chart is meant to avoid. Returns "true" +or "". +*/}} +{{- define "hyperfleet-api.tlsEnabled" -}} +{{- if .Values.config.existingConfigMap -}} +{{- if not (hasKey .Values.config "existingConfigMapTLSEnabled") -}} +{{- fail "config.existingConfigMapTLSEnabled (true/false) must be set when config.existingConfigMap is set, so the chart knows whether to advertise the API port as http or https" -}} +{{- end -}} +{{- if .Values.config.existingConfigMapTLSEnabled }}true{{ end -}} +{{- else -}} +{{- if .Values.config.server.tls.enabled }}true{{ end -}} +{{- end -}} +{{- end }} + {{/* API container/Service port name: must match the API listener's actual protocol so Kubernetes-aware proxies and service meshes route correctly. */}} {{- define "hyperfleet-api.apiPortName" -}} -{{- if .Values.config.server.tls.enabled }}https{{ else }}http{{ end }} +{{- if (include "hyperfleet-api.tlsEnabled" .) }}https{{ else }}http{{ end -}} {{- end }} {{/* diff --git a/charts/templates/service.yaml b/charts/templates/service.yaml index 7d1185bb..9c8eaf3b 100644 --- a/charts/templates/service.yaml +++ b/charts/templates/service.yaml @@ -11,7 +11,7 @@ spec: targetPort: {{ include "hyperfleet-api.apiPortName" . }} protocol: TCP name: {{ include "hyperfleet-api.apiPortName" . }} - {{- if .Values.config.server.tls.enabled }} + {{- if (include "hyperfleet-api.tlsEnabled" .) }} appProtocol: https {{- end }} - port: {{ .Values.ports.health | default 8080 }} diff --git a/charts/values.yaml b/charts/values.yaml index cd1f8f81..02df2077 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -40,6 +40,12 @@ config: # other `config.*` values are ignored. existingConfigMap: "" + # -- Required (true/false) when `config.existingConfigMap` is set: declares + # whether that ConfigMap's config.yaml enables server TLS, since Helm can't + # read the mounted file's content. Deliberately has no default here -- + # rendering fails until set explicitly. Not read when existingConfigMap is "". + # existingConfigMapTLSEnabled: true + # -- HTTP server settings server: # -- Public hostname advertised by the API (leave empty for auto-detect) diff --git a/docs/deployment.md b/docs/deployment.md index 2c400ceb..f25f6894 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -187,20 +187,28 @@ See [Issuer configuration reference](authentication.md#issuer-configuration-refe ## Configuring TLS -TLS on the API listener is **disabled by default**. Enable it with `config.server.tls.*`: +TLS on the API listener is **disabled by default**. Enable it with `config.server.tls.*`, and mount the certificate and key into the container with `extraVolumes`/`extraVolumeMounts` — `config.server.tls.cert_file`/`key_file` only tell the API where to read them from, they don't mount anything: ```bash +kubectl create secret tls hyperfleet-api-tls \ + --namespace hyperfleet-system \ + --cert=tls.crt --key=tls.key + helm install hyperfleet-api oci://quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-api-chart: \ --namespace hyperfleet-system \ --set config.server.tls.enabled=true \ --set config.server.tls.cert_file=/etc/hyperfleet/tls/tls.crt \ - --set config.server.tls.key_file=/etc/hyperfleet/tls/tls.key + --set config.server.tls.key_file=/etc/hyperfleet/tls/tls.key \ + --set-json 'extraVolumes=[{"name":"tls","secret":{"secretName":"hyperfleet-api-tls"}}]' \ + --set-json 'extraVolumeMounts=[{"name":"tls","mountPath":"/etc/hyperfleet/tls","readOnly":true}]' ``` When `config.server.tls.enabled=true`, the chart renames the API container port and Service port from `http` to `https` and sets `appProtocol: https` on the Service, so Kubernetes-aware proxies and service meshes route to the listener as TLS instead of plaintext. **Clients must use `https://` against this Service once TLS is enabled** — a plaintext request to the `https` port fails with `Client sent an HTTP request to an HTTPS server`. If TLS instead terminates in front of the API — at an ingress or gateway — configure that ingress/gateway's backend for plain HTTP (matching `config.server.tls.enabled=false`), or use TLS passthrough/re-encryption so it still speaks TLS to the pod. Mixing the two (API TLS enabled with an ingress also terminating and re-encrypting as HTTP) reproduces the same protocol mismatch. +> **Note:** When `config.existingConfigMap` is set, `config.server.tls.enabled` is ignored along with the rest of `config.*` (see [Configuring Tenant Enforcement](#configuring-tenant-enforcement)). Set `config.existingConfigMapTLSEnabled` to `true` or `false` to match that ConfigMap's actual `server.tls.enabled` — the chart fails to render until this is set explicitly, rather than guessing and risking the same protocol mismatch. + --- ## Configuring Tenant Enforcement diff --git a/scripts/test-helm.sh b/scripts/test-helm.sh index 351e0cac..231099bc 100755 --- a/scripts/test-helm.sh +++ b/scripts/test-helm.sh @@ -117,6 +117,31 @@ fi echo "$OUTPUT" | kubeconform_validate pass "API TLS enabled port naming config template" +run_test "existingConfigMap without existingConfigMapTLSEnabled fails" +OUTPUT=$(render --set config.existingConfigMap=my-external-config 2>&1 || true) +assert_contains "$OUTPUT" 'existingConfigMapTLSEnabled' "expected error to mention existingConfigMapTLSEnabled" +pass "existingConfigMap requires explicit TLS declaration" + +run_test "existingConfigMap with existingConfigMapTLSEnabled=true: https port naming and appProtocol" +OUTPUT=$(render \ + --set config.existingConfigMap=my-external-config \ + --set config.existingConfigMapTLSEnabled=true) +assert_contains "$OUTPUT" 'name: https' "expected https container/Service port name" +assert_contains "$OUTPUT" 'targetPort: https' "expected Service targetPort: https" +assert_contains "$OUTPUT" 'appProtocol: https' "expected Service appProtocol: https" +echo "$OUTPUT" | kubeconform_validate +pass "existingConfigMap with TLS enabled port naming config template" + +run_test "existingConfigMap with existingConfigMapTLSEnabled=false: http port naming unchanged" +OUTPUT=$(render \ + --set config.existingConfigMap=my-external-config \ + --set config.existingConfigMapTLSEnabled=false) +assert_contains "$OUTPUT" 'name: http' "expected http container/Service port name" +assert_contains "$OUTPUT" 'targetPort: http' "expected Service targetPort: http" +assert_not_contains "$OUTPUT" 'appProtocol' "unexpected appProtocol with TLS disabled" +echo "$OUTPUT" | kubeconform_validate +pass "existingConfigMap with TLS disabled port naming config template" + run_test "template with external database" render \ --set database.postgresql.enabled=false \ From ad75b1df3e0477726567bfab7482739329293b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michaela=20Pa=C5=99ilov=C3=A1?= Date: Fri, 9 Oct 2026 10:55:44 +0200 Subject: [PATCH 26/41] HYPERFLEET-1740 - fix: enforce FIPS-compliant build flags --- Dockerfile | 14 +++++++------- Makefile | 10 ++++++---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index 93313e6f..cb2c6932 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG BASE_IMAGE=registry.access.redhat.com/ubi9-micro:latest +ARG BASE_IMAGE=registry.access.redhat.com/ubi9/ubi-minimal:latest FROM registry.access.redhat.com/ubi9/go-toolset:9.8-1790174511 AS builder @@ -26,22 +26,22 @@ RUN --mount=type=cache,target=/opt/app-root/src/go/pkg/mod,uid=1001 \ COPY --chown=1001:0 . . -# CGO_ENABLED=0 produces a static binary. The default ubi9-micro runtime -# supports both static and dynamically linked binaries. -# For FIPS-compliant builds, use CGO_ENABLED=1 + GOEXPERIMENT=boringcrypto. RUN --mount=type=cache,target=/opt/app-root/src/go/pkg/mod,uid=1001 \ --mount=type=cache,target=/opt/app-root/src/.cache/go-build,uid=1001 \ - CGO_ENABLED=0 GOOS=linux \ + GOOS=linux \ GIT_SHA=${GIT_SHA} GIT_DIRTY=${GIT_DIRTY} BUILD_DATE=${BUILD_DATE} \ make build +# Fail the build if the binary wasn't compiled with FIPS-compliant +# flags (see container-image-standard.md) +RUN go version -m /build/bin/hyperfleet-api | grep -q "CGO_ENABLED=1" || { echo "FIPS check FAILED: missing CGO_ENABLED=1"; exit 1; } +RUN go version -m /build/bin/hyperfleet-api | grep -q "GOEXPERIMENT=.*boringcrypto" || { echo "FIPS check FAILED: missing GOEXPERIMENT=boringcrypto"; exit 1; } + # Runtime stage FROM ${BASE_IMAGE} WORKDIR /app -# ubi9-micro doesn't include CA certificates; copy from builder for TLS (e.g. Google Pub/Sub) -COPY --from=builder /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem COPY --from=builder /build/bin/hyperfleet-api /app/hyperfleet-api COPY --from=builder /build/openapi/openapi.yaml /app/openapi/openapi.yaml COPY --from=builder /build/LICENSE /licenses/LICENSE diff --git a/Makefile b/Makefile index b96e093a..10639d13 100755 --- a/Makefile +++ b/Makefile @@ -1,8 +1,10 @@ .DEFAULT_GOAL := help # CGO_ENABLED=0 is not FIPS compliant. large commercial vendors and FedRAMP require FIPS compliant crypto -# Use ?= to allow Dockerfile to override (CGO_ENABLED=0 for Alpine-based dev images) +# Use ?= so callers can override on the command line, e.g. CGO_ENABLED=0 make build for a non-FIPS static binary CGO_ENABLED ?= 1 +GOEXPERIMENT ?= boringcrypto +GOFIPS140 ?= off GO ?= go @@ -156,11 +158,11 @@ generate-vendor: generate build: generate-all ## Build the hyperfleet-api binary @mkdir -p bin @echo "Building version: ${APP_VERSION}" - CGO_ENABLED=$(CGO_ENABLED) GOEXPERIMENT=boringcrypto GOFIPS140=off ${GO} build $(GOFLAGS) -ldflags="$(LDFLAGS)" -o bin/hyperfleet-api ./cmd/hyperfleet-api + CGO_ENABLED=$(CGO_ENABLED) GOEXPERIMENT=$(GOEXPERIMENT) GOFIPS140=$(GOFIPS140) ${GO} build $(GOFLAGS) -ldflags="$(LDFLAGS)" -o bin/hyperfleet-api ./cmd/hyperfleet-api .PHONY: install install: generate-all ## Build and install binary to GOPATH/bin - CGO_ENABLED=$(CGO_ENABLED) GOEXPERIMENT=boringcrypto GOFIPS140=off ${GO} install $(GOFLAGS) -ldflags="$(LDFLAGS)" ./cmd/hyperfleet-api + CGO_ENABLED=$(CGO_ENABLED) GOEXPERIMENT=$(GOEXPERIMENT) GOFIPS140=$(GOFIPS140) ${GO} install $(GOFLAGS) -ldflags="$(LDFLAGS)" ./cmd/hyperfleet-api # Common CLI flags for local database access DB_FLAGS = --db-host localhost --db-port $(db_port) --db-name $(db_name) \ @@ -194,7 +196,7 @@ run/docs: check-container-tool ## Run swagger and host the api spec cmds: ## Build all binaries under cmd/ @mkdir -p bin for cmd in $$(ls cmd); do \ - CGO_ENABLED=$(CGO_ENABLED) GOEXPERIMENT=boringcrypto GOFIPS140=off ${GO} build \ + CGO_ENABLED=$(CGO_ENABLED) GOEXPERIMENT=$(GOEXPERIMENT) GOFIPS140=$(GOFIPS140) ${GO} build \ $(GOFLAGS) \ -ldflags="$(LDFLAGS)" \ -o "bin/$${cmd}" \ From 45556218907f640350bc0fb779c77483b5376b91 Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Fri, 9 Oct 2026 12:11:22 -0300 Subject: [PATCH 27/41] HYPERFLEET-1679 - fix: close remaining TLS-advertisement mismatch paths --- charts/README.md | 2 +- charts/templates/_helpers.tpl | 11 +++++++++-- charts/values.schema.json | 4 ++++ charts/values.yaml | 5 ++++- scripts/test-helm.sh | 20 ++++++++++++++++++++ 5 files changed, 38 insertions(+), 4 deletions(-) diff --git a/charts/README.md b/charts/README.md index 1ffae2aa..7689cb99 100644 --- a/charts/README.md +++ b/charts/README.md @@ -196,7 +196,7 @@ helm install hyperfleet-api oci://REGISTRY/hyperfleet-api \ | validationSchema.enabled | bool | `false` | Enable spec validation | | validationSchema.existingConfigMap | string | `""` | Use an existing ConfigMap (must contain an `openapi.yaml` key). When set, `validationSchema.content` is ignored. | | validationSchema.content | string | `""` | Inline OpenAPI 3.0 schema content. Must define spec schemas matching `spec_schema_name` for each entity (e.g. `ClusterSpec`, `NodePoolSpec`) under `components.schemas`. | -| extraEnv | list | `[]` | Additional environment variables injected into the API container. Use sparingly — prefer `config.*` values above. | +| extraEnv | list | `[]` | Additional environment variables injected into the API container. Use sparingly — prefer `config.*` values above. Must not set HYPERFLEET_SERVER_TLS_ENABLED: rendering fails if it does, since that env var would override config.server.tls.enabled at runtime without the chart knowing, leaving the Service/container port advertising the wrong protocol. | | extraVolumeMounts | list | `[]` | Extra volume mounts added to the API container | | extraVolumes | list | `[]` | Extra volumes added to the pod | diff --git a/charts/templates/_helpers.tpl b/charts/templates/_helpers.tpl index 7e4914cd..cff82490 100644 --- a/charts/templates/_helpers.tpl +++ b/charts/templates/_helpers.tpl @@ -75,10 +75,17 @@ Whether the API listener effectively has TLS enabled. config.server.tls.enabled is ignored when config.existingConfigMap is set (its config.yaml is opaque to Helm), so that case requires the operator to declare config.existingConfigMapTLSEnabled explicitly instead of silently guessing and -risking the exact protocol mismatch this chart is meant to avoid. Returns "true" -or "". +risking the exact protocol mismatch this chart is meant to avoid. extraEnv is +rejected if it sets HYPERFLEET_SERVER_TLS_ENABLED, since env vars take +precedence over the ConfigMap at runtime and could silently diverge from what +this helper computes. Returns "true" or "". */}} {{- define "hyperfleet-api.tlsEnabled" -}} +{{- range .Values.extraEnv -}} +{{- if eq .name "HYPERFLEET_SERVER_TLS_ENABLED" -}} +{{- fail "extraEnv must not set HYPERFLEET_SERVER_TLS_ENABLED -- use config.server.tls.enabled instead, so the chart advertises the matching Service/container port protocol" -}} +{{- end -}} +{{- end -}} {{- if .Values.config.existingConfigMap -}} {{- if not (hasKey .Values.config "existingConfigMapTLSEnabled") -}} {{- fail "config.existingConfigMapTLSEnabled (true/false) must be set when config.existingConfigMap is set, so the chart knows whether to advertise the API port as http or https" -}} diff --git a/charts/values.schema.json b/charts/values.schema.json index e6247cd9..0c070ce2 100644 --- a/charts/values.schema.json +++ b/charts/values.schema.json @@ -89,6 +89,10 @@ "type": "string", "description": "Name of an existing ConfigMap to use instead of generating one; when set, all other config.* values are ignored" }, + "existingConfigMapTLSEnabled": { + "type": "boolean", + "description": "Required when existingConfigMap is set: whether that ConfigMap's config.yaml enables server TLS" + }, "server": { "type": "object", "description": "HTTP server settings", diff --git a/charts/values.yaml b/charts/values.yaml index 02df2077..9a9bc06b 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -592,7 +592,10 @@ validationSchema: content: "" # -- Additional environment variables injected into the API container. -# Use sparingly — prefer `config.*` values above. +# Use sparingly — prefer `config.*` values above. Must not set +# HYPERFLEET_SERVER_TLS_ENABLED: rendering fails if it does, since that env +# var would override config.server.tls.enabled at runtime without the chart +# knowing, leaving the Service/container port advertising the wrong protocol. extraEnv: [] # -- Extra volume mounts added to the API container diff --git a/scripts/test-helm.sh b/scripts/test-helm.sh index 231099bc..f17292c1 100755 --- a/scripts/test-helm.sh +++ b/scripts/test-helm.sh @@ -142,6 +142,26 @@ assert_not_contains "$OUTPUT" 'appProtocol' "unexpected appProtocol with TLS dis echo "$OUTPUT" | kubeconform_validate pass "existingConfigMap with TLS disabled port naming config template" +run_test "existingConfigMapTLSEnabled as a quoted string fails schema validation" +OUTPUT=$(render \ + --set config.existingConfigMap=my-external-config \ + --set-string config.existingConfigMapTLSEnabled=false 2>&1 || true) +assert_contains "$OUTPUT" 'existingConfigMapTLSEnabled' "expected schema validation error to reference existingConfigMapTLSEnabled" +assert_contains "$OUTPUT" 'want boolean' "expected schema validation error to reject a non-boolean value" +pass "existingConfigMapTLSEnabled rejects a quoted string (Go template if would otherwise treat \"false\" as truthy)" + +run_test "extraEnv setting HYPERFLEET_SERVER_TLS_ENABLED fails" +OUTPUT=$(render --set-json 'extraEnv=[{"name":"HYPERFLEET_SERVER_TLS_ENABLED","value":"true"}]' 2>&1 || true) +assert_contains "$OUTPUT" 'HYPERFLEET_SERVER_TLS_ENABLED' "expected error to mention HYPERFLEET_SERVER_TLS_ENABLED" +pass "extraEnv TLS override is rejected" + +run_test "extraEnv with an unrelated variable still renders" +OUTPUT=$(render --set-json 'extraEnv=[{"name":"SOME_OTHER_VAR","value":"x"}]') +assert_contains "$OUTPUT" 'SOME_OTHER_VAR' "expected extraEnv variable to be rendered" +assert_contains "$OUTPUT" 'name: http' "expected http port naming unaffected by unrelated extraEnv" +echo "$OUTPUT" | kubeconform_validate +pass "extraEnv with unrelated variable config template" + run_test "template with external database" render \ --set database.postgresql.enabled=false \ From 1530b9c432061c54452ae25453f4d9fb1555f1f0 Mon Sep 17 00:00:00 2001 From: Leonardo Dorneles Date: Fri, 9 Oct 2026 17:54:38 -0300 Subject: [PATCH 28/41] HYPERFLEET-1679 - fix: correct extraEnv TLS-guard assertion --- scripts/test-helm.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/test-helm.sh b/scripts/test-helm.sh index f17292c1..d5495018 100755 --- a/scripts/test-helm.sh +++ b/scripts/test-helm.sh @@ -147,12 +147,15 @@ OUTPUT=$(render \ --set config.existingConfigMap=my-external-config \ --set-string config.existingConfigMapTLSEnabled=false 2>&1 || true) assert_contains "$OUTPUT" 'existingConfigMapTLSEnabled' "expected schema validation error to reference existingConfigMapTLSEnabled" -assert_contains "$OUTPUT" 'want boolean' "expected schema validation error to reject a non-boolean value" +assert_contains "$OUTPUT" 'boolean' "expected schema validation error to reject a non-boolean value" pass "existingConfigMapTLSEnabled rejects a quoted string (Go template if would otherwise treat \"false\" as truthy)" run_test "extraEnv setting HYPERFLEET_SERVER_TLS_ENABLED fails" -OUTPUT=$(render --set-json 'extraEnv=[{"name":"HYPERFLEET_SERVER_TLS_ENABLED","value":"true"}]' 2>&1 || true) -assert_contains "$OUTPUT" 'HYPERFLEET_SERVER_TLS_ENABLED' "expected error to mention HYPERFLEET_SERVER_TLS_ENABLED" +if OUTPUT=$(render --set-json 'extraEnv=[{"name":"HYPERFLEET_SERVER_TLS_ENABLED","value":"true"}]' 2>&1); then + fail "expected render to fail when extraEnv sets HYPERFLEET_SERVER_TLS_ENABLED, but render succeeded" +else + assert_contains "$OUTPUT" 'HYPERFLEET_SERVER_TLS_ENABLED' "expected error to mention HYPERFLEET_SERVER_TLS_ENABLED" +fi pass "extraEnv TLS override is rejected" run_test "extraEnv with an unrelated variable still renders" From 6451cba79650be9a2f10d864df8adb2af469bd8c Mon Sep 17 00:00:00 2001 From: "red-hat-konflux-kflux-prd-rh02[bot]" <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:04:40 +0000 Subject: [PATCH 29/41] Update Konflux references 0.x Signed-off-by: red-hat-konflux-kflux-prd-rh02 <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> --- .tekton/hyperfleet-api-chart-push.yaml | 10 +++------ .tekton/hyperfleet-api-chart-tag.yaml | 10 +++------ .tekton/hyperfleet-api-push.yaml | 29 +++++++++++--------------- .tekton/hyperfleet-api-tag.yaml | 29 +++++++++++--------------- 4 files changed, 30 insertions(+), 48 deletions(-) diff --git a/.tekton/hyperfleet-api-chart-push.yaml b/.tekton/hyperfleet-api-chart-push.yaml index f1f07b0e..294458fa 100644 --- a/.tekton/hyperfleet-api-chart-push.yaml +++ b/.tekton/hyperfleet-api-chart-push.yaml @@ -170,7 +170,7 @@ spec: - name: name value: build-helm-chart-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.3@sha256:cc59ae16baa756d23f3e5f8328cd9f8ba39a388f8eafbb9db666dea694814d25 + value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.4@sha256:914908783734e764b33ae4f090d1fbf92eca336bae916f30629869bfe9408210 - name: kind value: task resolver: bundles @@ -182,8 +182,6 @@ spec: value: $(tasks.build-helm-chart.results.IMAGE_URL) - name: SOURCE_ARTIFACT value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) - name: TARGET_DIRS value: $(params.sast-target-dirs) runAfter: @@ -193,7 +191,7 @@ spec: - name: name value: sast-shell-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:d00c884c78489a52dc638e6ced0a01429670d0754f5b1305ad576709cf660312 + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1.1@sha256:a3bcbf008dc76df0d7d91d17b4e2cf925395fe9470361283d25bba73958c7281 - name: kind value: task resolver: bundles @@ -210,8 +208,6 @@ spec: value: $(tasks.build-helm-chart.results.IMAGE_URL) - name: SOURCE_ARTIFACT value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) - name: TARGET_DIRS value: $(params.sast-target-dirs) runAfter: @@ -221,7 +217,7 @@ spec: - name: name value: sast-unicode-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:15d654c9576b9c4ee1f78b09fce541acf9afe33f1c2363fd6219a79dbf62c95d + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4.1@sha256:ec176a34ddf4392e06d6904b4b2b2a58e34db0487114e2a476796b1310760fad - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-chart-tag.yaml b/.tekton/hyperfleet-api-chart-tag.yaml index 7001111f..c28f26d2 100644 --- a/.tekton/hyperfleet-api-chart-tag.yaml +++ b/.tekton/hyperfleet-api-chart-tag.yaml @@ -203,7 +203,7 @@ spec: - name: name value: build-helm-chart-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.3@sha256:cc59ae16baa756d23f3e5f8328cd9f8ba39a388f8eafbb9db666dea694814d25 + value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.4@sha256:914908783734e764b33ae4f090d1fbf92eca336bae916f30629869bfe9408210 - name: kind value: task resolver: bundles @@ -215,8 +215,6 @@ spec: value: $(tasks.build-helm-chart.results.IMAGE_URL) - name: SOURCE_ARTIFACT value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) - name: TARGET_DIRS value: $(params.sast-target-dirs) runAfter: @@ -226,7 +224,7 @@ spec: - name: name value: sast-shell-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:d00c884c78489a52dc638e6ced0a01429670d0754f5b1305ad576709cf660312 + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1.1@sha256:a3bcbf008dc76df0d7d91d17b4e2cf925395fe9470361283d25bba73958c7281 - name: kind value: task resolver: bundles @@ -243,8 +241,6 @@ spec: value: $(tasks.build-helm-chart.results.IMAGE_URL) - name: SOURCE_ARTIFACT value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) - name: TARGET_DIRS value: $(params.sast-target-dirs) runAfter: @@ -254,7 +250,7 @@ spec: - name: name value: sast-unicode-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:15d654c9576b9c4ee1f78b09fce541acf9afe33f1c2363fd6219a79dbf62c95d + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4.1@sha256:ec176a34ddf4392e06d6904b4b2b2a58e34db0487114e2a476796b1310760fad - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-push.yaml b/.tekton/hyperfleet-api-push.yaml index ffc6507f..ce95d069 100644 --- a/.tekton/hyperfleet-api-push.yaml +++ b/.tekton/hyperfleet-api-push.yaml @@ -222,8 +222,6 @@ spec: value: - $(params.build-args[*]) - APP_VERSION=dev - - name: BUILD_ARGS_FILE - value: $(params.build-args-file) - name: PRIVILEGED_NESTED value: $(params.privileged-nested) - name: SOURCE_URL @@ -244,6 +242,9 @@ spec: value: $(params.rewrite-timestamp) - name: OMIT_HISTORY value: $(params.omit-history) + - name: BUILD_ARGS_FILES + value: + - $(params.build-args-file) runAfter: - prefetch-dependencies taskRef: @@ -251,7 +252,7 @@ spec: - name: name value: buildah-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.12.3@sha256:3c57ec441d23c81d73df3b1f95cd5e800d53674fa0e8af784d81402e087e30bf + value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.13.0@sha256:5962a3531a56ab393e6590c4ecedebd5d7ace3fe57f6cc37bec6b4ea838204cf - name: kind value: task resolver: bundles @@ -316,7 +317,7 @@ spec: - name: name value: deprecated-image-check - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-deprecated-image-check:0.5@sha256:0ccc688a77e9b7b0b8973c132a1e840844137e77f887be4a0bec8893b0776872 + value: quay.io/konflux-ci/tekton-catalog/task-deprecated-image-check:0.5@sha256:81b7cad8d5fd0148b157d622f4064f57d38aa33c142275050c8a4727d1e2144c - name: kind value: task resolver: bundles @@ -338,7 +339,7 @@ spec: - name: name value: roxctl-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-roxctl-scan:0.1@sha256:8286d4dd5a337596ed4776d68152656abed9a464777dbb87b00256fd5987789e + value: quay.io/konflux-ci/tekton-catalog/task-roxctl-scan:0.1@sha256:e398b9ddfc422639c2e245285577b01b8c25cc141a2563e1d85449d96b619752 - name: kind value: task resolver: bundles @@ -377,8 +378,6 @@ spec: value: $(params.sast-target-dirs) - name: SOURCE_ARTIFACT value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) runAfter: - build-image-index taskRef: @@ -386,7 +385,7 @@ spec: - name: name value: sast-snyk-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.5@sha256:17b9587eba53fabaab35061275306bfe1f99661d55e14e22f62321fa7cb5ff3e + value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.5.1@sha256:d01cd72c9884bd6db08dcc558e6cb6a5f408bc905a3aaa7ece6cf080c933108f - name: kind value: task resolver: bundles @@ -408,7 +407,7 @@ spec: - name: name value: clamav-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3.4@sha256:22fb08a2d08000aadf22a9cbaaa8265ba70e25fe4e24d81a3f3e64440d9e6b5e + value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3.4@sha256:6fe7e04cef9d999aeb62a29839df82a24fd4841c93998bb0c4ef3cd9a69288c2 - name: kind value: task resolver: bundles @@ -427,8 +426,6 @@ spec: value: $(params.sast-target-dirs) - name: SOURCE_ARTIFACT value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) runAfter: - build-image-index taskRef: @@ -436,7 +433,7 @@ spec: - name: name value: sast-shell-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:d00c884c78489a52dc638e6ced0a01429670d0754f5b1305ad576709cf660312 + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1.1@sha256:a3bcbf008dc76df0d7d91d17b4e2cf925395fe9470361283d25bba73958c7281 - name: kind value: task resolver: bundles @@ -455,8 +452,6 @@ spec: value: $(params.sast-target-dirs) - name: SOURCE_ARTIFACT value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) runAfter: - build-image-index taskRef: @@ -464,7 +459,7 @@ spec: - name: name value: sast-unicode-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:15d654c9576b9c4ee1f78b09fce541acf9afe33f1c2363fd6219a79dbf62c95d + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4.1@sha256:ec176a34ddf4392e06d6904b4b2b2a58e34db0487114e2a476796b1310760fad - name: kind value: task resolver: bundles @@ -486,7 +481,7 @@ spec: - name: name value: apply-tags - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.3.1@sha256:c177fc2b2482b1299e713c51bf258eba4972c016ef260d9f45335c2550dc7b2b + value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.3.2@sha256:a1d5e6d0912de4e39a19b4aa5587526cb8b20260ac32d00ac5c8c4581e3c2ea1 - name: kind value: task resolver: bundles @@ -526,7 +521,7 @@ spec: - name: name value: rpms-signature-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:cc1b939d933d24727ef4b90e1c5c65a6d2a6c1c036cf74f685b597d611fa150f + value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:f72e0baac32f1fc70e441cb2e84b7d1d54857bf520e0d7db68ae35174fb46709 - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-tag.yaml b/.tekton/hyperfleet-api-tag.yaml index b43e6993..532705e5 100644 --- a/.tekton/hyperfleet-api-tag.yaml +++ b/.tekton/hyperfleet-api-tag.yaml @@ -252,8 +252,6 @@ spec: value: - $(params.build-args[*]) - APP_VERSION=$(tasks.extract-version.results.VERSION) - - name: BUILD_ARGS_FILE - value: $(params.build-args-file) - name: PRIVILEGED_NESTED value: $(params.privileged-nested) - name: SOURCE_URL @@ -274,6 +272,9 @@ spec: value: $(params.rewrite-timestamp) - name: OMIT_HISTORY value: $(params.omit-history) + - name: BUILD_ARGS_FILES + value: + - $(params.build-args-file) runAfter: - prefetch-dependencies taskRef: @@ -281,7 +282,7 @@ spec: - name: name value: buildah-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.12.3@sha256:3c57ec441d23c81d73df3b1f95cd5e800d53674fa0e8af784d81402e087e30bf + value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.13.0@sha256:5962a3531a56ab393e6590c4ecedebd5d7ace3fe57f6cc37bec6b4ea838204cf - name: kind value: task resolver: bundles @@ -346,7 +347,7 @@ spec: - name: name value: deprecated-image-check - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-deprecated-image-check:0.5@sha256:0ccc688a77e9b7b0b8973c132a1e840844137e77f887be4a0bec8893b0776872 + value: quay.io/konflux-ci/tekton-catalog/task-deprecated-image-check:0.5@sha256:81b7cad8d5fd0148b157d622f4064f57d38aa33c142275050c8a4727d1e2144c - name: kind value: task resolver: bundles @@ -368,7 +369,7 @@ spec: - name: name value: roxctl-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-roxctl-scan:0.1@sha256:8286d4dd5a337596ed4776d68152656abed9a464777dbb87b00256fd5987789e + value: quay.io/konflux-ci/tekton-catalog/task-roxctl-scan:0.1@sha256:e398b9ddfc422639c2e245285577b01b8c25cc141a2563e1d85449d96b619752 - name: kind value: task resolver: bundles @@ -407,8 +408,6 @@ spec: value: $(params.sast-target-dirs) - name: SOURCE_ARTIFACT value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) runAfter: - build-image-index taskRef: @@ -416,7 +415,7 @@ spec: - name: name value: sast-snyk-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.5@sha256:17b9587eba53fabaab35061275306bfe1f99661d55e14e22f62321fa7cb5ff3e + value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.5.1@sha256:d01cd72c9884bd6db08dcc558e6cb6a5f408bc905a3aaa7ece6cf080c933108f - name: kind value: task resolver: bundles @@ -438,7 +437,7 @@ spec: - name: name value: clamav-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3.4@sha256:22fb08a2d08000aadf22a9cbaaa8265ba70e25fe4e24d81a3f3e64440d9e6b5e + value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3.4@sha256:6fe7e04cef9d999aeb62a29839df82a24fd4841c93998bb0c4ef3cd9a69288c2 - name: kind value: task resolver: bundles @@ -457,8 +456,6 @@ spec: value: $(params.sast-target-dirs) - name: SOURCE_ARTIFACT value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) runAfter: - build-image-index taskRef: @@ -466,7 +463,7 @@ spec: - name: name value: sast-shell-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1@sha256:d00c884c78489a52dc638e6ced0a01429670d0754f5b1305ad576709cf660312 + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1.1@sha256:a3bcbf008dc76df0d7d91d17b4e2cf925395fe9470361283d25bba73958c7281 - name: kind value: task resolver: bundles @@ -485,8 +482,6 @@ spec: value: $(params.sast-target-dirs) - name: SOURCE_ARTIFACT value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) runAfter: - build-image-index taskRef: @@ -494,7 +489,7 @@ spec: - name: name value: sast-unicode-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4@sha256:15d654c9576b9c4ee1f78b09fce541acf9afe33f1c2363fd6219a79dbf62c95d + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4.1@sha256:ec176a34ddf4392e06d6904b4b2b2a58e34db0487114e2a476796b1310760fad - name: kind value: task resolver: bundles @@ -516,7 +511,7 @@ spec: - name: name value: apply-tags - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.3.1@sha256:c177fc2b2482b1299e713c51bf258eba4972c016ef260d9f45335c2550dc7b2b + value: quay.io/konflux-ci/tekton-catalog/task-apply-tags:0.3.2@sha256:a1d5e6d0912de4e39a19b4aa5587526cb8b20260ac32d00ac5c8c4581e3c2ea1 - name: kind value: task resolver: bundles @@ -556,7 +551,7 @@ spec: - name: name value: rpms-signature-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:cc1b939d933d24727ef4b90e1c5c65a6d2a6c1c036cf74f685b597d611fa150f + value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:f72e0baac32f1fc70e441cb2e84b7d1d54857bf520e0d7db68ae35174fb46709 - name: kind value: task resolver: bundles From 850cde2f4b4c090a3c84c13b3b785cc70d090ad1 Mon Sep 17 00:00:00 2001 From: "red-hat-konflux-kflux-prd-rh02[bot]" <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:05:24 +0000 Subject: [PATCH 30/41] Update registry.access.redhat.com/ubi9/go-toolset Docker tag to v9.8-1791479310 Signed-off-by: red-hat-konflux-kflux-prd-rh02 <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index cb2c6932..744b05f4 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ ARG BASE_IMAGE=registry.access.redhat.com/ubi9/ubi-minimal:latest -FROM registry.access.redhat.com/ubi9/go-toolset:9.8-1790174511 AS builder +FROM registry.access.redhat.com/ubi9/go-toolset:9.8-1791479310 AS builder ARG GIT_SHA=unknown ARG GIT_DIRTY="" From 9ecabeb5e1466846fe1f40d0d2706c76431ba92f Mon Sep 17 00:00:00 2001 From: "red-hat-konflux-kflux-prd-rh02[bot]" <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:06:16 +0000 Subject: [PATCH 31/41] Update gomod non-major Signed-off-by: red-hat-konflux-kflux-prd-rh02 <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> --- go.mod | 35 +++++++++++++-------------- go.sum | 74 ++++++++++++++++++++++++++++++---------------------------- 2 files changed, 56 insertions(+), 53 deletions(-) diff --git a/go.mod b/go.mod index 683ef0be..5e3ce45e 100755 --- a/go.mod +++ b/go.mod @@ -20,8 +20,8 @@ require ( github.com/onsi/gomega v1.43.0 github.com/openshift-hyperfleet/hyperfleet-api-spec v1.0.27 github.com/openshift-hyperfleet/hyperfleet-logger v0.0.0-20260811173525-c9f9e282d029 - github.com/prometheus/client_golang v1.24.1 - github.com/prometheus/client_model v0.6.2 + github.com/prometheus/client_golang v1.25.0 + github.com/prometheus/client_model v0.6.3 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 @@ -30,12 +30,12 @@ require ( github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 github.com/yaacov/tree-search-language/v6 v6.0.11 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 - go.opentelemetry.io/otel v1.46.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 - go.opentelemetry.io/otel/sdk v1.46.0 - go.opentelemetry.io/otel/trace v1.46.0 + go.opentelemetry.io/otel v1.47.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 + go.opentelemetry.io/otel/sdk v1.47.0 + go.opentelemetry.io/otel/trace v1.47.0 go.uber.org/mock v0.6.0 gopkg.in/resty.v1 v1.12.0 gorm.io/datatypes v1.2.7 @@ -54,6 +54,7 @@ require ( go.opentelemetry.io/contrib/propagators/b3 v1.46.0 // indirect go.opentelemetry.io/contrib/propagators/jaeger v1.46.0 // indirect go.opentelemetry.io/contrib/propagators/ot v1.46.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect golang.org/x/time v0.15.0 // indirect @@ -95,7 +96,7 @@ require ( github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/klauspost/compress v1.19.1 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/leodido/go-urn v1.5.0 // indirect github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect github.com/magiconair/properties v1.8.10 // indirect @@ -114,8 +115,8 @@ require ( github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.4.3 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/prometheus/common v0.70.1 // indirect - github.com/prometheus/procfs v0.21.1 // indirect + github.com/prometheus/common v0.72.0 // indirect + github.com/prometheus/procfs v0.22.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/shirou/gopsutil/v4 v4.26.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect @@ -127,18 +128,18 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/propagators/autoprop v0.71.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect - go.opentelemetry.io/otel/metric v1.46.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect + go.opentelemetry.io/otel/metric v1.47.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/crypto v0.57.0 // indirect - golang.org/x/net v0.58.0 // indirect + golang.org/x/net v0.59.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect - google.golang.org/grpc v1.83.1 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect + google.golang.org/grpc v1.83.2 // indirect google.golang.org/protobuf v1.36.12 // indirect gorm.io/driver/mysql v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index c4a6819d..077cc249 100644 --- a/go.sum +++ b/go.sum @@ -128,8 +128,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= -github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= -github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -197,14 +197,14 @@ github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= -github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= -github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= -github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= -github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0= +github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk= +github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4= +github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk= +github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= +github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -263,24 +263,26 @@ go.opentelemetry.io/contrib/propagators/jaeger v1.46.0 h1:uxl0SGcmuBkHj/Adl9oftE go.opentelemetry.io/contrib/propagators/jaeger v1.46.0/go.mod h1:LiOkxCIvoLofmRps7f8l0NkBtmObnAyQ5trteFs6wj8= go.opentelemetry.io/contrib/propagators/ot v1.46.0 h1:SxC/1xmJJJdGQTNmavqW8ju85TRXiZHeFaQdnEMCWLE= go.opentelemetry.io/contrib/propagators/ot v1.46.0/go.mod h1:ZrntlVZOzRMetoU2y7eIHUbJ7riKEOqIyLXkuu91sxU= -go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= -go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 h1:KdRxPiAoMptR3vfWzvjjvutTsSiwbC2uG0496rzZNfo= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0/go.mod h1:K/qSA+3G7Eovxi4K09wzrAgkWRnosS0DAOZeEpve7sM= -go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= -go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= -go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= -go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= -go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= -go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= -go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= -go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 h1:UFpxOpYPmMNUtOWhdb+nC1WELIgVf8z9higURrbzYU4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0/go.mod h1:YBGjxe3lt0jtXoEXxGrGhv/jM7oUBUDVK7zhAvNyEjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 h1:aXZXsZ012wOgVkqaiQv+Z/d8V0xKjmg70F6m4CE94lc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0/go.mod h1:ziu1gUIJhAaxM3EV1e3Ralk5AAyL1UOYz3lJJ4VSI38= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0 h1:N3YQCxjxQ/bMjyc3heladfRm9t9RTksGQH8z4w6yU/0= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.47.0/go.mod h1:Mp8HOFqcaUyypCuGv9IhDdTHnJ56lSudSHMd+pVSCEA= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= +go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= +go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= +go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -299,8 +301,8 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -319,12 +321,12 @@ golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= -google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= -google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= -google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From d9357fe1496a33892ff6bd894fc8b07558e202d8 Mon Sep 17 00:00:00 2001 From: Ciaran Roche Date: Tue, 13 Oct 2026 10:40:57 +0100 Subject: [PATCH 32/41] HYPERFLEET-1762 - chore: narrow OWNERS to area owners and aliases --- .tekton/OWNERS | 7 +++++ OWNERS | 41 +++++------------------------- OWNERS_ALIASES | 55 ++++++++++++++++++++++++++++++++++++++++ charts/dashboards/OWNERS | 7 +++++ openapi/OWNERS | 7 +++++ pkg/api/OWNERS | 7 +++++ pkg/auth/OWNERS | 7 +++++ pkg/dao/OWNERS | 7 +++++ pkg/db/OWNERS | 8 ++++++ pkg/handlers/OWNERS | 7 +++++ pkg/metrics/OWNERS | 7 +++++ pkg/middleware/OWNERS | 7 +++++ pkg/registry/OWNERS | 7 +++++ pkg/services/OWNERS | 7 +++++ pkg/telemetry/OWNERS | 7 +++++ pkg/tenant/OWNERS | 7 +++++ pkg/validation/OWNERS | 7 +++++ pkg/validators/OWNERS | 7 +++++ 18 files changed, 174 insertions(+), 35 deletions(-) create mode 100644 .tekton/OWNERS create mode 100644 OWNERS_ALIASES create mode 100644 charts/dashboards/OWNERS create mode 100644 openapi/OWNERS create mode 100644 pkg/api/OWNERS create mode 100644 pkg/auth/OWNERS create mode 100644 pkg/dao/OWNERS create mode 100644 pkg/db/OWNERS create mode 100644 pkg/handlers/OWNERS create mode 100644 pkg/metrics/OWNERS create mode 100644 pkg/middleware/OWNERS create mode 100644 pkg/registry/OWNERS create mode 100644 pkg/services/OWNERS create mode 100644 pkg/telemetry/OWNERS create mode 100644 pkg/tenant/OWNERS create mode 100644 pkg/validation/OWNERS create mode 100644 pkg/validators/OWNERS diff --git a/.tekton/OWNERS b/.tekton/OWNERS new file mode 100644 index 00000000..dccb32f2 --- /dev/null +++ b/.tekton/OWNERS @@ -0,0 +1,7 @@ +approvers: +- release-owners +reviewers: +- release-reviewers +- release-owners +labels: +- area/release diff --git a/OWNERS b/OWNERS index afaffaed..74552db7 100644 --- a/OWNERS +++ b/OWNERS @@ -1,37 +1,8 @@ approvers: -- aredenba-rh -- ciaranRoche -- crizzo71 -- jsell-rh -- mbrudnoy -- Mischulee -- pnguyen44 -- rafabene -- rh-amarin -- Ruclo -- tirthct -- vkareh -- kuudori -- ma-hill -- ldornele -- sherine-k -- mliptak0 - +- api-owners +- tech-leads reviewers: -- aredenba-rh -- ciaranRoche -- crizzo71 -- jsell-rh -- mbrudnoy -- Mischulee -- pnguyen44 -- rafabene -- rh-amarin -- Ruclo -- tirthct -- vkareh -- kuudori -- ma-hill -- ldornele -- sherine-k -- mliptak0 +- api-reviewers +- api-owners +labels: +- area/api diff --git a/OWNERS_ALIASES b/OWNERS_ALIASES new file mode 100644 index 00000000..58274d75 --- /dev/null +++ b/OWNERS_ALIASES @@ -0,0 +1,55 @@ +aliases: + api-owners: + - kuudori + - pnguyen44 + api-reviewers: + - tirthct + - Mischulee + - ma-hill + - mliptak0 + - ldornele + - rafabene + contracts-owners: + - rh-amarin + - mliptak0 + - kuudori + - ciaranRoche + contracts-reviewers: + - pnguyen44 + - Mischulee + - tirthct + generic-model-owners: + - kuudori + - pnguyen44 + generic-model-reviewers: + - tirthct + - Mischulee + - ma-hill + - mliptak0 + observability-owners: + - pnguyen44 + - kuudori + observability-reviewers: + - ldornele + - tirthct + - Ruclo + release-owners: + - ma-hill + - kuudori + release-reviewers: + - tirthct + - Mischulee + search-reviewers: + - Mischulee + - ma-hill + - mliptak0 + tech-leads: + - ciaranRoche + - rafabene + - rh-amarin + tenancy-owners: + - pnguyen44 + - mliptak0 + tenancy-reviewers: + - kuudori + - Mischulee diff --git a/charts/dashboards/OWNERS b/charts/dashboards/OWNERS new file mode 100644 index 00000000..ba29833c --- /dev/null +++ b/charts/dashboards/OWNERS @@ -0,0 +1,7 @@ +approvers: +- observability-owners +reviewers: +- observability-reviewers +- observability-owners +labels: +- area/observability diff --git a/openapi/OWNERS b/openapi/OWNERS new file mode 100644 index 00000000..914eac3c --- /dev/null +++ b/openapi/OWNERS @@ -0,0 +1,7 @@ +approvers: +- contracts-owners +reviewers: +- contracts-reviewers +- contracts-owners +labels: +- area/contracts diff --git a/pkg/api/OWNERS b/pkg/api/OWNERS new file mode 100644 index 00000000..0f1dc4a3 --- /dev/null +++ b/pkg/api/OWNERS @@ -0,0 +1,7 @@ +approvers: +- generic-model-owners +reviewers: +- search-reviewers +- generic-model-owners +labels: +- area/search diff --git a/pkg/auth/OWNERS b/pkg/auth/OWNERS new file mode 100644 index 00000000..a6765367 --- /dev/null +++ b/pkg/auth/OWNERS @@ -0,0 +1,7 @@ +approvers: +- tenancy-owners +reviewers: +- tenancy-reviewers +- tenancy-owners +labels: +- area/auth diff --git a/pkg/dao/OWNERS b/pkg/dao/OWNERS new file mode 100644 index 00000000..d432a881 --- /dev/null +++ b/pkg/dao/OWNERS @@ -0,0 +1,7 @@ +approvers: +- generic-model-owners +reviewers: +- generic-model-reviewers +- generic-model-owners +labels: +- area/generic-model diff --git a/pkg/db/OWNERS b/pkg/db/OWNERS new file mode 100644 index 00000000..85fd1110 --- /dev/null +++ b/pkg/db/OWNERS @@ -0,0 +1,8 @@ +approvers: +- generic-model-owners +reviewers: +- generic-model-reviewers +- generic-model-owners +labels: +- area/generic-model +- area/search diff --git a/pkg/handlers/OWNERS b/pkg/handlers/OWNERS new file mode 100644 index 00000000..d432a881 --- /dev/null +++ b/pkg/handlers/OWNERS @@ -0,0 +1,7 @@ +approvers: +- generic-model-owners +reviewers: +- generic-model-reviewers +- generic-model-owners +labels: +- area/generic-model diff --git a/pkg/metrics/OWNERS b/pkg/metrics/OWNERS new file mode 100644 index 00000000..ba29833c --- /dev/null +++ b/pkg/metrics/OWNERS @@ -0,0 +1,7 @@ +approvers: +- observability-owners +reviewers: +- observability-reviewers +- observability-owners +labels: +- area/observability diff --git a/pkg/middleware/OWNERS b/pkg/middleware/OWNERS new file mode 100644 index 00000000..a6765367 --- /dev/null +++ b/pkg/middleware/OWNERS @@ -0,0 +1,7 @@ +approvers: +- tenancy-owners +reviewers: +- tenancy-reviewers +- tenancy-owners +labels: +- area/auth diff --git a/pkg/registry/OWNERS b/pkg/registry/OWNERS new file mode 100644 index 00000000..d432a881 --- /dev/null +++ b/pkg/registry/OWNERS @@ -0,0 +1,7 @@ +approvers: +- generic-model-owners +reviewers: +- generic-model-reviewers +- generic-model-owners +labels: +- area/generic-model diff --git a/pkg/services/OWNERS b/pkg/services/OWNERS new file mode 100644 index 00000000..d432a881 --- /dev/null +++ b/pkg/services/OWNERS @@ -0,0 +1,7 @@ +approvers: +- generic-model-owners +reviewers: +- generic-model-reviewers +- generic-model-owners +labels: +- area/generic-model diff --git a/pkg/telemetry/OWNERS b/pkg/telemetry/OWNERS new file mode 100644 index 00000000..ba29833c --- /dev/null +++ b/pkg/telemetry/OWNERS @@ -0,0 +1,7 @@ +approvers: +- observability-owners +reviewers: +- observability-reviewers +- observability-owners +labels: +- area/observability diff --git a/pkg/tenant/OWNERS b/pkg/tenant/OWNERS new file mode 100644 index 00000000..1f87861f --- /dev/null +++ b/pkg/tenant/OWNERS @@ -0,0 +1,7 @@ +approvers: +- tenancy-owners +reviewers: +- tenancy-reviewers +- tenancy-owners +labels: +- area/tenancy diff --git a/pkg/validation/OWNERS b/pkg/validation/OWNERS new file mode 100644 index 00000000..0f1dc4a3 --- /dev/null +++ b/pkg/validation/OWNERS @@ -0,0 +1,7 @@ +approvers: +- generic-model-owners +reviewers: +- search-reviewers +- generic-model-owners +labels: +- area/search diff --git a/pkg/validators/OWNERS b/pkg/validators/OWNERS new file mode 100644 index 00000000..0f1dc4a3 --- /dev/null +++ b/pkg/validators/OWNERS @@ -0,0 +1,7 @@ +approvers: +- generic-model-owners +reviewers: +- search-reviewers +- generic-model-owners +labels: +- area/search From bbdb882256e6f33fe3deb7277513dac3617f8d9f Mon Sep 17 00:00:00 2001 From: "red-hat-konflux-kflux-prd-rh02[bot]" <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:16:49 +0000 Subject: [PATCH 33/41] Update Konflux references digest (#399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta](https://redirect.github.com/konflux-ci/tekton-tools/tree/cf53f00bb0b2b6a52999d31d9411c9cf18ddb233/tasks/build-helm-chart-oci-ta/0.4) ([source](https://redirect.github.com/konflux-ci/tekton-tools/tree/HEAD/task/build-helm-chart-oci-ta), [changelog](https://redirect.github.com/konflux-ci/tekton-tools/blob/main/task/build-helm-chart-oci-ta/CHANGELOG.md)) | tekton-bundle | digest | `9149087` → `60153fc` | | [quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta](https://redirect.github.com/konflux-ci/container-build-catalog/tree/73412d8905f04013b2e89a4d707f2787d20fd637/task/buildah-oci-ta) ([source](https://redirect.github.com/konflux-ci/container-build-catalog/tree/HEAD/task/buildah-oci-ta), [changelog](https://redirect.github.com/konflux-ci/container-build-catalog/blob/main/task/buildah-oci-ta/CHANGELOG.md)) | tekton-bundle | minor | `0.13.0` → `0.14.0` | | [quay.io/konflux-ci/tekton-catalog/task-clamav-scan](https://redirect.github.com/konflux-ci/konflux-test-tasks/tree/62d088763ce201dc610cc758dcb41059de828433/task/clamav-scan/0.3) ([source](https://redirect.github.com/konflux-ci/konflux-test-tasks/tree/HEAD/task/clamav-scan), [changelog](https://redirect.github.com/konflux-ci/konflux-test-tasks/blob/main/task/clamav-scan/CHANGELOG.md)) | tekton-bundle | digest | `6fe7e04` → `6c7e4e2` | | [quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks](https://redirect.github.com/konflux-ci/build-definitions/tree/84409b2cfb5cadd19bb6b5b28873569d34ef72fd/task/ecosystem-cert-preflight-checks/0.2) ([source](https://redirect.github.com/konflux-ci/build-definitions/tree/HEAD/task/ecosystem-cert-preflight-checks), [changelog](https://redirect.github.com/konflux-ci/build-definitions/blob/main/task/ecosystem-cert-preflight-checks/CHANGELOG.md)) | tekton-bundle | digest | `4390141` → `cc7d71d` | | [quay.io/konflux-ci/tekton-catalog/task-init](https://redirect.github.com/konflux-ci/build-definitions/tree/fa51c97e2792750a8c3a055205455f1b1cd1b224/task/init/0.4) ([source](https://redirect.github.com/konflux-ci/build-definitions/tree/HEAD/task/init), [changelog](https://redirect.github.com/konflux-ci/build-definitions/blob/main/task/init/CHANGELOG.md)) | tekton-bundle | digest | `7854d7b` → `5197c2d` | | [quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta](https://redirect.github.com/konflux-ci/container-build-catalog/tree/73412d8905f04013b2e89a4d707f2787d20fd637/task/prefetch-dependencies-oci-ta) ([source](https://redirect.github.com/konflux-ci/container-build-catalog/tree/HEAD/task/prefetch-dependencies-oci-ta), [changelog](https://redirect.github.com/konflux-ci/container-build-catalog/blob/main/task/prefetch-dependencies-oci-ta/CHANGELOG.md)) | tekton-bundle | minor | `0.10.3` → `0.11.0` | | [quay.io/konflux-ci/tekton-catalog/task-roxctl-scan](https://redirect.github.com/konflux-ci/konflux-test-tasks/tree/2aaa5a3fccbf553b98ed01bb1f37a585833f1fde/task/roxctl-scan/0.1) ([source](https://redirect.github.com/konflux-ci/konflux-test-tasks/tree/HEAD/task/roxctl-scan), [changelog](https://redirect.github.com/konflux-ci/konflux-test-tasks/blob/main/task/roxctl-scan/CHANGELOG.md)) | tekton-bundle | patch | `0.1` → `0.1.1` | | [quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan](https://redirect.github.com/konflux-ci/tekton-tools/tree/cf53f00bb0b2b6a52999d31d9411c9cf18ddb233/tasks/rpms-signature-scan/0.2) ([source](https://redirect.github.com/konflux-ci/tekton-tools/tree/HEAD/task/rpms-signature-scan), [changelog](https://redirect.github.com/konflux-ci/tekton-tools/blob/main/task/rpms-signature-scan/CHANGELOG.md)) | tekton-bundle | digest | `f72e0ba` → `f3d7323` | | [quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta](https://redirect.github.com/konflux-ci/konflux-sast-tasks/tree/b601167eddf740199af2dbfdef4aa823f429740e/task/sast-shell-check-oci-ta/0.1,!task/sast-shell-check-oci-ta/0.1/recipe.yaml) ([source](https://redirect.github.com/konflux-ci/konflux-sast-tasks/tree/HEAD/task/sast-shell-check-oci-ta), [changelog](https://redirect.github.com/konflux-ci/konflux-sast-tasks/blob/main/task/sast-shell-check-oci-ta/CHANGELOG.md)) | tekton-bundle | digest | `a3bcbf0` → `8372228` | | [quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta](https://redirect.github.com/konflux-ci/konflux-sast-tasks/tree/45ff1ec08d11036e73c265484d17323019076cf6/task/sast-snyk-check-oci-ta/0.5,!task/sast-snyk-check-oci-ta/0.5/recipe.yaml) ([source](https://redirect.github.com/konflux-ci/konflux-sast-tasks/tree/HEAD/task/sast-snyk-check-oci-ta), [changelog](https://redirect.github.com/konflux-ci/konflux-sast-tasks/blob/main/task/sast-snyk-check-oci-ta/CHANGELOG.md)) | tekton-bundle | digest | `d01cd72` → `53443fe` | | [quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta](https://redirect.github.com/konflux-ci/konflux-sast-tasks/tree/b9014148aba1a21c0395bd315f4d7b2eac3f971a/task/sast-unicode-check-oci-ta/0.4,!task/sast-unicode-check-oci-ta/0.4/recipe.yaml) ([source](https://redirect.github.com/konflux-ci/konflux-sast-tasks/tree/HEAD/task/sast-unicode-check-oci-ta), [changelog](https://redirect.github.com/konflux-ci/konflux-sast-tasks/blob/main/task/sast-unicode-check-oci-ta/CHANGELOG.md)) | tekton-bundle | digest | `ec176a3` → `2ee8973` | | [quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta](https://redirect.github.com/konflux-ci/container-build-catalog/tree/73412d8905f04013b2e89a4d707f2787d20fd637/task/source-build-oci-ta) ([source](https://redirect.github.com/konflux-ci/container-build-catalog/tree/HEAD/task/source-build-oci-ta), [changelog](https://redirect.github.com/konflux-ci/container-build-catalog/blob/main/task/source-build-oci-ta/CHANGELOG.md)) | tekton-bundle | minor | `0.3.2` → `0.4.0` | --- ### Release Notes
konflux-ci/container-build-catalog (quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta) ### [`v0.14.0`](https://redirect.github.com/konflux-ci/container-build-catalog/blob/HEAD/task/buildah-oci-ta/CHANGELOG.md#0140) ##### Changed - Renamed Trusted Artifact parameter `CACHI2_ARTIFACT` to `PREFETCH_ARTIFACT`. - Renamed Trusted Artifact path `/var/workdir/cachi2` to `/var/workdir/prefetch`. Recommended to use `prefetch-dependencies-oci-ta` >= 0.11.0 for naming consistency of the parameter.
konflux-ci/container-build-catalog (quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta) ### [`v0.11.0`](https://redirect.github.com/konflux-ci/container-build-catalog/blob/HEAD/task/prefetch-dependencies-oci-ta/CHANGELOG.md#0110) ##### Changed - Renamed Trusted Artifact result `CACHI2_ARTIFACT` to `PREFETCH_ARTIFACT`. - Renamed Trusted Artifact path `/var/workdir/cachi2` to `/var/workdir/prefetch`. Recommended to use `buildah-oci-ta` >= 0.14.0 and `source-build-oci-ta` >= 0.4.0 for naming consistency of the result. ### [`v0.10.4`](https://redirect.github.com/konflux-ci/container-build-catalog/blob/HEAD/task/prefetch-dependencies-oci-ta/CHANGELOG.md#0104) - Hermeto release -
konflux-ci/konflux-test-tasks (quay.io/konflux-ci/tekton-catalog/task-roxctl-scan) ### [`v0.1.1`](https://redirect.github.com/konflux-ci/konflux-test-tasks/blob/HEAD/task/roxctl-scan/CHANGELOG.md#011) ##### Changed - Updated rox-image-scan and exchange-service-account-token steps image to version *4.11.5* by following digest registry.redhat.io/advanced-cluster-security/rhacs-roxctl-rhel9\@​sha256:98f01d623e119acbbc211bdeb36192b619969aa149d9c66bab8034eeef84c861
konflux-ci/container-build-catalog (quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta) ### [`v0.4.0`](https://redirect.github.com/konflux-ci/container-build-catalog/blob/HEAD/task/source-build-oci-ta/CHANGELOG.md#040) ##### Changed - Renamed Trusted Artifact parameter `CACHI2_ARTIFACT` to `PREFETCH_ARTIFACT`. - Renamed Trusted Artifact path `/var/workdir/cachi2` to `/var/workdir/prefetch`. Recommended to use `prefetch-dependencies-oci-ta` >= 0.11.0 for naming consistency of the parameter.
--- ### Configuration 📅 **Schedule**: (in timezone UTC) - Branch creation - Only on Saturday (`* * * * 6`) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- To execute skipped test pipelines write comment `/ok-to-test`. --- ### Documentation Find out how to configure dependency updates in [MintMaker documentation](https://konflux-ci.dev/docs/mintmaker/user/) or see all available configuration options in [Renovate documentation](https://docs.renovatebot.com/configuration-options/). Signed-off-by: red-hat-konflux-kflux-prd-rh02 <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> Co-authored-by: red-hat-konflux-kflux-prd-rh02[bot] <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> --- .tekton/hyperfleet-api-chart-push.yaml | 10 ++--- .tekton/hyperfleet-api-chart-tag.yaml | 10 ++--- .tekton/hyperfleet-api-push.yaml | 53 +++++++++++--------------- .tekton/hyperfleet-api-tag.yaml | 50 +++++++++++------------- 4 files changed, 55 insertions(+), 68 deletions(-) diff --git a/.tekton/hyperfleet-api-chart-push.yaml b/.tekton/hyperfleet-api-chart-push.yaml index 294458fa..fc77ae0b 100644 --- a/.tekton/hyperfleet-api-chart-push.yaml +++ b/.tekton/hyperfleet-api-chart-push.yaml @@ -97,7 +97,7 @@ spec: - name: name value: init - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:7854d7babe4136e53e337f6433d952532314433b48bb62b422ddc02700934d85 + value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:5197c2d81b0d0babe7468e8c06a9281fdf0654bba369faca4492233dc88f54e4 - name: kind value: task resolver: bundles @@ -144,7 +144,7 @@ spec: - name: name value: prefetch-dependencies-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.3@sha256:0c386f2c26b46b978280c5c54caac26bcd524975f8f7cc06bb080ba00eae3ad4 + value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.11.0@sha256:7ffdaab6322c4bed64297eb79f767d92609a3e24bd123b14dc73536c038bf5b7 - name: kind value: task resolver: bundles @@ -170,7 +170,7 @@ spec: - name: name value: build-helm-chart-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.4@sha256:914908783734e764b33ae4f090d1fbf92eca336bae916f30629869bfe9408210 + value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.4@sha256:60153fca674adf0be0e7e2c358403ece505738cbe29a5ca3ea518977fab6d6cf - name: kind value: task resolver: bundles @@ -191,7 +191,7 @@ spec: - name: name value: sast-shell-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1.1@sha256:a3bcbf008dc76df0d7d91d17b4e2cf925395fe9470361283d25bba73958c7281 + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1.1@sha256:83722286ec3297e3f66b0c220cec8895e0f745337e464c1d1c038ed737cf69fd - name: kind value: task resolver: bundles @@ -217,7 +217,7 @@ spec: - name: name value: sast-unicode-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4.1@sha256:ec176a34ddf4392e06d6904b4b2b2a58e34db0487114e2a476796b1310760fad + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4.1@sha256:2ee89730856f542d851d99b5d945963583a840c295354dc4257a0052841e35ab - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-chart-tag.yaml b/.tekton/hyperfleet-api-chart-tag.yaml index c28f26d2..bd61e1d2 100644 --- a/.tekton/hyperfleet-api-chart-tag.yaml +++ b/.tekton/hyperfleet-api-chart-tag.yaml @@ -103,7 +103,7 @@ spec: - name: name value: init - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:7854d7babe4136e53e337f6433d952532314433b48bb62b422ddc02700934d85 + value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:5197c2d81b0d0babe7468e8c06a9281fdf0654bba369faca4492233dc88f54e4 - name: kind value: task resolver: bundles @@ -173,7 +173,7 @@ spec: - name: name value: prefetch-dependencies-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.3@sha256:0c386f2c26b46b978280c5c54caac26bcd524975f8f7cc06bb080ba00eae3ad4 + value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.11.0@sha256:7ffdaab6322c4bed64297eb79f767d92609a3e24bd123b14dc73536c038bf5b7 - name: kind value: task resolver: bundles @@ -203,7 +203,7 @@ spec: - name: name value: build-helm-chart-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.4@sha256:914908783734e764b33ae4f090d1fbf92eca336bae916f30629869bfe9408210 + value: quay.io/konflux-ci/tekton-catalog/task-build-helm-chart-oci-ta:0.4@sha256:60153fca674adf0be0e7e2c358403ece505738cbe29a5ca3ea518977fab6d6cf - name: kind value: task resolver: bundles @@ -224,7 +224,7 @@ spec: - name: name value: sast-shell-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1.1@sha256:a3bcbf008dc76df0d7d91d17b4e2cf925395fe9470361283d25bba73958c7281 + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1.1@sha256:83722286ec3297e3f66b0c220cec8895e0f745337e464c1d1c038ed737cf69fd - name: kind value: task resolver: bundles @@ -250,7 +250,7 @@ spec: - name: name value: sast-unicode-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4.1@sha256:ec176a34ddf4392e06d6904b4b2b2a58e34db0487114e2a476796b1310760fad + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4.1@sha256:2ee89730856f542d851d99b5d945963583a840c295354dc4257a0052841e35ab - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-push.yaml b/.tekton/hyperfleet-api-push.yaml index ce95d069..32092d2b 100644 --- a/.tekton/hyperfleet-api-push.yaml +++ b/.tekton/hyperfleet-api-push.yaml @@ -7,9 +7,8 @@ metadata: build.appstudio.redhat.com/target_branch: '{{target_branch}}' pipelinesascode.tekton.dev/cancel-in-progress: "false" pipelinesascode.tekton.dev/max-keep-runs: "3" - pipelinesascode.tekton.dev/on-cel-expression: event == "push" && target_branch - == "main" - creationTimestamp: null + pipelinesascode.tekton.dev/on-cel-expression: event == "push" && target_branch == "main" + creationTimestamp: labels: appstudio.openshift.io/application: hyperfleet appstudio.openshift.io/component: hyperfleet-api @@ -48,13 +47,11 @@ spec: name: output-image type: string - default: . - description: Path to the source code of an application's component from where - to build image. + description: Path to the source code of an application's component from where to build image. name: path-context type: string - default: Dockerfile - description: Path to the Dockerfile inside the context specified by parameter - path-context + description: Path to the Dockerfile inside the context specified by parameter path-context name: dockerfile type: string - default: "false" @@ -70,8 +67,7 @@ spec: name: prefetch-input type: string - default: "" - description: Image tag expiration time, time values could be something like - 1h, 2d, 3w for hours, days, and weeks, respectively. + description: Image tag expiration time, time values could be something like 1h, 2d, 3w for hours, days, and weeks, respectively. name: image-expires-after type: string - default: "false" @@ -83,8 +79,7 @@ spec: name: build-image-index type: string - default: docker - description: The format for the resulting image's mediaType. Valid values are - oci or docker. + description: The format for the resulting image's mediaType. Valid values are oci or docker. name: buildah-format type: string - default: "false" @@ -94,8 +89,7 @@ spec: description: Use the package registry proxy when prefetching dependencies name: enable-package-registry-proxy - default: . - description: Target directories in component's source code to scan with SAST - tools. Multiple values should be separated with commas. + description: Target directories in component's source code to scan with SAST tools. Multiple values should be separated with commas. name: sast-target-dirs type: string - default: [] @@ -107,8 +101,7 @@ spec: name: build-args-file type: string - default: "false" - description: Whether to enable privileged mode, should be used only with remote - VMs + description: Whether to enable privileged mode, should be used only with remote VMs name: privileged-nested type: string - name: source-date-epoch @@ -146,7 +139,7 @@ spec: - name: name value: init - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:7854d7babe4136e53e337f6433d952532314433b48bb62b422ddc02700934d85 + value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:5197c2d81b0d0babe7468e8c06a9281fdf0654bba369faca4492233dc88f54e4 - name: kind value: task resolver: bundles @@ -193,7 +186,7 @@ spec: - name: name value: prefetch-dependencies-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.3@sha256:0c386f2c26b46b978280c5c54caac26bcd524975f8f7cc06bb080ba00eae3ad4 + value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.11.0@sha256:7ffdaab6322c4bed64297eb79f767d92609a3e24bd123b14dc73536c038bf5b7 - name: kind value: task resolver: bundles @@ -234,8 +227,6 @@ spec: value: $(tasks.init.results.no-proxy) - name: SOURCE_ARTIFACT value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) - name: SOURCE_DATE_EPOCH value: $(params.source-date-epoch) - name: REWRITE_TIMESTAMP @@ -245,6 +236,8 @@ spec: - name: BUILD_ARGS_FILES value: - $(params.build-args-file) + - name: PREFETCH_ARTIFACT + value: $(tasks.prefetch-dependencies.results.PREFETCH_ARTIFACT) runAfter: - prefetch-dependencies taskRef: @@ -252,7 +245,7 @@ spec: - name: name value: buildah-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.13.0@sha256:5962a3531a56ab393e6590c4ecedebd5d7ace3fe57f6cc37bec6b4ea838204cf + value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.14.0@sha256:1cca06769911f4b5a2ce534660674b2267b1a4f4fa17d8c88cb2be4e3e113c57 - name: kind value: task resolver: bundles @@ -286,8 +279,8 @@ spec: value: $(tasks.build-image-index.results.IMAGE_DIGEST) - name: SOURCE_ARTIFACT value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + - name: PREFETCH_ARTIFACT + value: $(tasks.prefetch-dependencies.results.PREFETCH_ARTIFACT) runAfter: - build-image-index taskRef: @@ -295,7 +288,7 @@ spec: - name: name value: source-build-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.3.2@sha256:45091b5022754d452b4de60c7d7f4c6e03626efff55f96e9af937400fe3506eb + value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.4.0@sha256:7a387194cf725b36acf92fd006ba97f0a9853d01f74273496b99146496b56ef7 - name: kind value: task resolver: bundles @@ -339,7 +332,7 @@ spec: - name: name value: roxctl-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-roxctl-scan:0.1@sha256:e398b9ddfc422639c2e245285577b01b8c25cc141a2563e1d85449d96b619752 + value: quay.io/konflux-ci/tekton-catalog/task-roxctl-scan:0.1.1@sha256:2fafbf5439702731ad24a09bed677c371fda1f8a7ecf5ed53f7b7f3c71079980 - name: kind value: task resolver: bundles @@ -359,7 +352,7 @@ spec: - name: name value: ecosystem-cert-preflight-checks - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:439014132f4521ea3221ea29b1fd83e48f9728c071db6edaf6b870550e5d89a6 + value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:cc7d71d9be01e9221d8753a1f1a7caaf0fb93048df69e9a95c359068dc730578 - name: kind value: task resolver: bundles @@ -385,7 +378,7 @@ spec: - name: name value: sast-snyk-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.5.1@sha256:d01cd72c9884bd6db08dcc558e6cb6a5f408bc905a3aaa7ece6cf080c933108f + value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.5.1@sha256:53443febc3a87ea6d4baacffc70d6f7dbdd1b4e69e0852337661097c73433519 - name: kind value: task resolver: bundles @@ -407,7 +400,7 @@ spec: - name: name value: clamav-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3.4@sha256:6fe7e04cef9d999aeb62a29839df82a24fd4841c93998bb0c4ef3cd9a69288c2 + value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3.4@sha256:6c7e4e239cb7f81b6e1bbfdfe6bda77c6902dd801afcad05a315c247ec5e7f88 - name: kind value: task resolver: bundles @@ -433,7 +426,7 @@ spec: - name: name value: sast-shell-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1.1@sha256:a3bcbf008dc76df0d7d91d17b4e2cf925395fe9470361283d25bba73958c7281 + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1.1@sha256:83722286ec3297e3f66b0c220cec8895e0f745337e464c1d1c038ed737cf69fd - name: kind value: task resolver: bundles @@ -459,7 +452,7 @@ spec: - name: name value: sast-unicode-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4.1@sha256:ec176a34ddf4392e06d6904b4b2b2a58e34db0487114e2a476796b1310760fad + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4.1@sha256:2ee89730856f542d851d99b5d945963583a840c295354dc4257a0052841e35ab - name: kind value: task resolver: bundles @@ -521,7 +514,7 @@ spec: - name: name value: rpms-signature-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:f72e0baac32f1fc70e441cb2e84b7d1d54857bf520e0d7db68ae35174fb46709 + value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:f3d7323f549c3e8af2fc0e0a9ae9bd9314922d65b0f163e4a9331430f7bbdbd3 - name: kind value: task resolver: bundles diff --git a/.tekton/hyperfleet-api-tag.yaml b/.tekton/hyperfleet-api-tag.yaml index 532705e5..8c3b2be5 100644 --- a/.tekton/hyperfleet-api-tag.yaml +++ b/.tekton/hyperfleet-api-tag.yaml @@ -10,7 +10,7 @@ metadata: pipelinesascode.tekton.dev/on-cel-expression: | event == "push" && target_branch.matches("^refs/tags/v[0-9]+\\.[0-9]+\\.[0-9]+(-rc[0-9]+)?$") - creationTimestamp: null + creationTimestamp: labels: appstudio.openshift.io/application: hyperfleet appstudio.openshift.io/component: hyperfleet-api @@ -51,13 +51,11 @@ spec: name: output-image type: string - default: . - description: Path to the source code of an application's component from where - to build image. + description: Path to the source code of an application's component from where to build image. name: path-context type: string - default: Dockerfile - description: Path to the Dockerfile inside the context specified by parameter - path-context + description: Path to the Dockerfile inside the context specified by parameter path-context name: dockerfile type: string - default: "false" @@ -73,8 +71,7 @@ spec: name: prefetch-input type: string - default: "" - description: Image tag expiration time, time values could be something like - 1h, 2d, 3w for hours, days, and weeks, respectively. + description: Image tag expiration time, time values could be something like 1h, 2d, 3w for hours, days, and weeks, respectively. name: image-expires-after type: string - default: "false" @@ -86,8 +83,7 @@ spec: name: build-image-index type: string - default: docker - description: The format for the resulting image's mediaType. Valid values are - oci or docker. + description: The format for the resulting image's mediaType. Valid values are oci or docker. name: buildah-format type: string - default: "false" @@ -97,8 +93,7 @@ spec: description: Use the package registry proxy when prefetching dependencies name: enable-package-registry-proxy - default: . - description: Target directories in component's source code to scan with SAST - tools. Multiple values should be separated with commas. + description: Target directories in component's source code to scan with SAST tools. Multiple values should be separated with commas. name: sast-target-dirs type: string - default: [] @@ -110,8 +105,7 @@ spec: name: build-args-file type: string - default: "false" - description: Whether to enable privileged mode, should be used only with remote - VMs + description: Whether to enable privileged mode, should be used only with remote VMs name: privileged-nested type: string - default: "" @@ -153,7 +147,7 @@ spec: - name: name value: init - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:7854d7babe4136e53e337f6433d952532314433b48bb62b422ddc02700934d85 + value: quay.io/konflux-ci/tekton-catalog/task-init:0.4.3@sha256:5197c2d81b0d0babe7468e8c06a9281fdf0654bba369faca4492233dc88f54e4 - name: kind value: task resolver: bundles @@ -223,7 +217,7 @@ spec: - name: name value: prefetch-dependencies-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.10.3@sha256:0c386f2c26b46b978280c5c54caac26bcd524975f8f7cc06bb080ba00eae3ad4 + value: quay.io/konflux-ci/tekton-catalog/task-prefetch-dependencies-oci-ta:0.11.0@sha256:7ffdaab6322c4bed64297eb79f767d92609a3e24bd123b14dc73536c038bf5b7 - name: kind value: task resolver: bundles @@ -264,8 +258,6 @@ spec: value: $(tasks.init.results.no-proxy) - name: SOURCE_ARTIFACT value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) - name: SOURCE_DATE_EPOCH value: $(params.source-date-epoch) - name: REWRITE_TIMESTAMP @@ -275,6 +267,8 @@ spec: - name: BUILD_ARGS_FILES value: - $(params.build-args-file) + - name: PREFETCH_ARTIFACT + value: $(tasks.prefetch-dependencies.results.PREFETCH_ARTIFACT) runAfter: - prefetch-dependencies taskRef: @@ -282,7 +276,7 @@ spec: - name: name value: buildah-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.13.0@sha256:5962a3531a56ab393e6590c4ecedebd5d7ace3fe57f6cc37bec6b4ea838204cf + value: quay.io/konflux-ci/tekton-catalog/task-buildah-oci-ta:0.14.0@sha256:1cca06769911f4b5a2ce534660674b2267b1a4f4fa17d8c88cb2be4e3e113c57 - name: kind value: task resolver: bundles @@ -316,8 +310,8 @@ spec: value: $(tasks.build-image-index.results.IMAGE_DIGEST) - name: SOURCE_ARTIFACT value: $(tasks.prefetch-dependencies.results.SOURCE_ARTIFACT) - - name: CACHI2_ARTIFACT - value: $(tasks.prefetch-dependencies.results.CACHI2_ARTIFACT) + - name: PREFETCH_ARTIFACT + value: $(tasks.prefetch-dependencies.results.PREFETCH_ARTIFACT) runAfter: - build-image-index taskRef: @@ -325,7 +319,7 @@ spec: - name: name value: source-build-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.3.2@sha256:45091b5022754d452b4de60c7d7f4c6e03626efff55f96e9af937400fe3506eb + value: quay.io/konflux-ci/tekton-catalog/task-source-build-oci-ta:0.4.0@sha256:7a387194cf725b36acf92fd006ba97f0a9853d01f74273496b99146496b56ef7 - name: kind value: task resolver: bundles @@ -369,7 +363,7 @@ spec: - name: name value: roxctl-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-roxctl-scan:0.1@sha256:e398b9ddfc422639c2e245285577b01b8c25cc141a2563e1d85449d96b619752 + value: quay.io/konflux-ci/tekton-catalog/task-roxctl-scan:0.1.1@sha256:2fafbf5439702731ad24a09bed677c371fda1f8a7ecf5ed53f7b7f3c71079980 - name: kind value: task resolver: bundles @@ -389,7 +383,7 @@ spec: - name: name value: ecosystem-cert-preflight-checks - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:439014132f4521ea3221ea29b1fd83e48f9728c071db6edaf6b870550e5d89a6 + value: quay.io/konflux-ci/tekton-catalog/task-ecosystem-cert-preflight-checks:0.2@sha256:cc7d71d9be01e9221d8753a1f1a7caaf0fb93048df69e9a95c359068dc730578 - name: kind value: task resolver: bundles @@ -415,7 +409,7 @@ spec: - name: name value: sast-snyk-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.5.1@sha256:d01cd72c9884bd6db08dcc558e6cb6a5f408bc905a3aaa7ece6cf080c933108f + value: quay.io/konflux-ci/tekton-catalog/task-sast-snyk-check-oci-ta:0.5.1@sha256:53443febc3a87ea6d4baacffc70d6f7dbdd1b4e69e0852337661097c73433519 - name: kind value: task resolver: bundles @@ -437,7 +431,7 @@ spec: - name: name value: clamav-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3.4@sha256:6fe7e04cef9d999aeb62a29839df82a24fd4841c93998bb0c4ef3cd9a69288c2 + value: quay.io/konflux-ci/tekton-catalog/task-clamav-scan:0.3.4@sha256:6c7e4e239cb7f81b6e1bbfdfe6bda77c6902dd801afcad05a315c247ec5e7f88 - name: kind value: task resolver: bundles @@ -463,7 +457,7 @@ spec: - name: name value: sast-shell-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1.1@sha256:a3bcbf008dc76df0d7d91d17b4e2cf925395fe9470361283d25bba73958c7281 + value: quay.io/konflux-ci/tekton-catalog/task-sast-shell-check-oci-ta:0.1.1@sha256:83722286ec3297e3f66b0c220cec8895e0f745337e464c1d1c038ed737cf69fd - name: kind value: task resolver: bundles @@ -489,7 +483,7 @@ spec: - name: name value: sast-unicode-check-oci-ta - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4.1@sha256:ec176a34ddf4392e06d6904b4b2b2a58e34db0487114e2a476796b1310760fad + value: quay.io/konflux-ci/tekton-catalog/task-sast-unicode-check-oci-ta:0.4.1@sha256:2ee89730856f542d851d99b5d945963583a840c295354dc4257a0052841e35ab - name: kind value: task resolver: bundles @@ -551,7 +545,7 @@ spec: - name: name value: rpms-signature-scan - name: bundle - value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:f72e0baac32f1fc70e441cb2e84b7d1d54857bf520e0d7db68ae35174fb46709 + value: quay.io/konflux-ci/tekton-catalog/task-rpms-signature-scan:0.2.2@sha256:f3d7323f549c3e8af2fc0e0a9ae9bd9314922d65b0f163e4a9331430f7bbdbd3 - name: kind value: task resolver: bundles From db3bd7c6d8bbf5454e4a799faaa1876d6ed5e382 Mon Sep 17 00:00:00 2001 From: "red-hat-konflux-kflux-prd-rh02[bot]" <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:19:57 +0000 Subject: [PATCH 34/41] Update registry.access.redhat.com/ubi9/go-toolset Docker tag to v9.8-1792010711 (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | registry.access.redhat.com/ubi9/go-toolset | stage | patch | `9.8-1791479310` → `9.8-1792010711` | --- ### Configuration 📅 **Schedule**: (in timezone UTC) - Branch creation - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- To execute skipped test pipelines write comment `/ok-to-test`. --- ### Documentation Find out how to configure dependency updates in [MintMaker documentation](https://konflux-ci.dev/docs/mintmaker/user/) or see all available configuration options in [Renovate documentation](https://docs.renovatebot.com/configuration-options/). Signed-off-by: red-hat-konflux-kflux-prd-rh02 <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> Co-authored-by: red-hat-konflux-kflux-prd-rh02[bot] <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 744b05f4..8744bea4 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ ARG BASE_IMAGE=registry.access.redhat.com/ubi9/ubi-minimal:latest -FROM registry.access.redhat.com/ubi9/go-toolset:9.8-1791479310 AS builder +FROM registry.access.redhat.com/ubi9/go-toolset:9.8-1792010711 AS builder ARG GIT_SHA=unknown ARG GIT_DIRTY="" From cc3e923d6243e4ea4b2346887d9ba1ea9829bdd5 Mon Sep 17 00:00:00 2001 From: "red-hat-konflux-kflux-prd-rh02[bot]" <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:05:23 +0000 Subject: [PATCH 35/41] Update opentelemetry-go-contrib monorepo to v0.72.0 (#401) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp](https://redirect.github.com/open-telemetry/opentelemetry-go-contrib) | `v0.71.0` → `v0.72.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/go.opentelemetry.io%2fcontrib%2finstrumentation%2fnet%2fhttp%2fotelhttp/v0.72.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/go.opentelemetry.io%2fcontrib%2finstrumentation%2fnet%2fhttp%2fotelhttp/v0.71.0/v0.72.0?slim=true) | | [go.opentelemetry.io/contrib/propagators/autoprop](https://redirect.github.com/open-telemetry/opentelemetry-go-contrib) | `v0.71.0` → `v0.72.0` | ![age](https://developer.mend.io/api/mc/badges/age/go/go.opentelemetry.io%2fcontrib%2fpropagators%2fautoprop/v0.72.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/go/go.opentelemetry.io%2fcontrib%2fpropagators%2fautoprop/v0.71.0/v0.72.0?slim=true) | --- ### Configuration 📅 **Schedule**: (in timezone UTC) - Branch creation - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] If you want to rebase/retry this PR, check this box --- To execute skipped test pipelines write comment `/ok-to-test`. --- ### Documentation Find out how to configure dependency updates in [MintMaker documentation](https://konflux-ci.dev/docs/mintmaker/user/) or see all available configuration options in [Renovate documentation](https://docs.renovatebot.com/configuration-options/). Signed-off-by: red-hat-konflux-kflux-prd-rh02 <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> Co-authored-by: red-hat-konflux-kflux-prd-rh02[bot] <190377777+red-hat-konflux-kflux-prd-rh02[bot]@users.noreply.github.com> --- go.mod | 12 ++++++------ go.sum | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index 5e3ce45e..d94f900e 100755 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( github.com/testcontainers/testcontainers-go v0.44.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 github.com/yaacov/tree-search-language/v6 v6.0.11 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 go.opentelemetry.io/otel v1.47.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.47.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 @@ -50,10 +50,10 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect - go.opentelemetry.io/contrib/propagators/aws v1.46.0 // indirect - go.opentelemetry.io/contrib/propagators/b3 v1.46.0 // indirect - go.opentelemetry.io/contrib/propagators/jaeger v1.46.0 // indirect - go.opentelemetry.io/contrib/propagators/ot v1.46.0 // indirect + go.opentelemetry.io/contrib/propagators/aws v1.47.0 // indirect + go.opentelemetry.io/contrib/propagators/b3 v1.47.0 // indirect + go.opentelemetry.io/contrib/propagators/jaeger v1.47.0 // indirect + go.opentelemetry.io/contrib/propagators/ot v1.47.0 // indirect go.opentelemetry.io/otel/log v1.47.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect @@ -127,7 +127,7 @@ require ( github.com/tklauser/numcpus v0.12.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/propagators/autoprop v0.71.0 + go.opentelemetry.io/contrib/propagators/autoprop v0.72.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 // indirect go.opentelemetry.io/otel/metric v1.47.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect diff --git a/go.sum b/go.sum index 077cc249..7c25091d 100644 --- a/go.sum +++ b/go.sum @@ -251,18 +251,18 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= -go.opentelemetry.io/contrib/propagators/autoprop v0.71.0 h1:uy7dcw6v3XK3LMGj2sB9CXtJtTHX8QA9pjSrLhvf86c= -go.opentelemetry.io/contrib/propagators/autoprop v0.71.0/go.mod h1:IknVHC4K/KkF0Kx+GpTlhlbW96XOq4m0O/FHauFVZtA= -go.opentelemetry.io/contrib/propagators/aws v1.46.0 h1:JslT1wq/5vb6lQsbdOqShvIEs7sDlf0IvKxNZdadfjY= -go.opentelemetry.io/contrib/propagators/aws v1.46.0/go.mod h1:JE4srRJf2cRJcJjRaNhViFjyFJqiCZJiDVlqe6GWXsA= -go.opentelemetry.io/contrib/propagators/b3 v1.46.0 h1:OFVqWObn7xLIbOjE/koO0LS9fZJNgAyBD0msA+UQAoc= -go.opentelemetry.io/contrib/propagators/b3 v1.46.0/go.mod h1:t/d64xy7xuuEDJN/4ThqohLgRhIuQxL9y7P1v02bYuM= -go.opentelemetry.io/contrib/propagators/jaeger v1.46.0 h1:uxl0SGcmuBkHj/Adl9oftEAyiawQBPL5RzMAmt/Yvq4= -go.opentelemetry.io/contrib/propagators/jaeger v1.46.0/go.mod h1:LiOkxCIvoLofmRps7f8l0NkBtmObnAyQ5trteFs6wj8= -go.opentelemetry.io/contrib/propagators/ot v1.46.0 h1:SxC/1xmJJJdGQTNmavqW8ju85TRXiZHeFaQdnEMCWLE= -go.opentelemetry.io/contrib/propagators/ot v1.46.0/go.mod h1:ZrntlVZOzRMetoU2y7eIHUbJ7riKEOqIyLXkuu91sxU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw= +go.opentelemetry.io/contrib/propagators/autoprop v0.72.0 h1:/Kv8DucXan87cGRpa3NeZtlk26kFahF2YrL6Sz3GFsI= +go.opentelemetry.io/contrib/propagators/autoprop v0.72.0/go.mod h1:Qttdialu54uM051nRjcHgqPshuTi2YGZb/rruiNJ8zo= +go.opentelemetry.io/contrib/propagators/aws v1.47.0 h1:n/2x/vQki1ojD7R2jYHdnNdI74Yx/OFdY4YXFFesm8M= +go.opentelemetry.io/contrib/propagators/aws v1.47.0/go.mod h1:qz0eA26XfmJUDbcqCZrn2HJ5WIETXpcMfiKRAEHRvmQ= +go.opentelemetry.io/contrib/propagators/b3 v1.47.0 h1:vlKDmtjhe1R0d7kdov/+KVTKWOKeGLyohB+iXoDPRMQ= +go.opentelemetry.io/contrib/propagators/b3 v1.47.0/go.mod h1:0zRiXIfXU30PKYMsdbqYbGK8iHF8KqyPi0qdXT2wAuE= +go.opentelemetry.io/contrib/propagators/jaeger v1.47.0 h1:2lDw6AKPV33/7Qw9oN5YdxuSJIlUz7q3tkfSlJX5rh0= +go.opentelemetry.io/contrib/propagators/jaeger v1.47.0/go.mod h1:y1ORt/QAquBL+O3I06KE7iMENp9p05iBqaV4xNF3J7k= +go.opentelemetry.io/contrib/propagators/ot v1.47.0 h1:nZUqh0/8CMRh7fQMfJl3HWFW92vqJ4FcTRQ7lrubOO4= +go.opentelemetry.io/contrib/propagators/ot v1.47.0/go.mod h1:K3kUrioG2mDmYF2xaEbjASrL0Slv6b7deOeV/4QofgU= go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54= From b6d607295d2b9893ce9d014dcb3de9ea8c3eb5aa Mon Sep 17 00:00:00 2001 From: Rafael Benevides Date: Mon, 19 Oct 2026 18:01:51 -0300 Subject: [PATCH 36/41] HYPERFLEET-1775 - docs: rename nested CLAUDE.md files to AGENTS.md (#402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Renames the nine nested `CLAUDE.md` files to `AGENTS.md` with `git mv` (content unchanged except reference updates) and adds a sibling one-line `CLAUDE.md` pointer (`@AGENTS.md`) next to each, mirroring the root pattern. References updated: - `AGENTS.md` (root): package doc index — 8 refs - `CONTRIBUTING.md`: repo tree and resources list now describe `AGENTS.md`; the root `CLAUDE.md` is shown as the pointer it is - `charts/.helmignore`: ignores `AGENTS.md` alongside `CLAUDE.md` - `pkg/db/README.md`: points to `pkg/db/migrations/AGENTS.md` - Cross-references between the renamed files ("Related ... Files" sections) ## Why `AGENTS.md` is the cross-tool standard (OpenCode, Codex, and Claude Code read it directly). Claude Code's default instruction setting skips nested `AGENTS.md` files when a root `CLAUDE.md` exists, so each nested `AGENTS.md` keeps a sibling `CLAUDE.md` pointer — verified empirically with Claude Code v2.1.291 that the pointer lazy-loads and expands the import (same pattern already merged for hyperfleet-applier in #67). Jira: https://redhat.atlassian.net/browse/HYPERFLEET-1775 ## Verification - Every `CLAUDE.md` in the repo is exactly the one-line `@AGENTS.md` pointer (root + 9 nested) - `grep -rn "CLAUDE"` returns only the intentional entries: `charts/.helmignore` (ignores both names) and the `CONTRIBUTING.md` tree line documenting the root pointer - `pre-commit run` passes on all changed files; pre-push hooks (gofmt, golangci-lint, go vet) pass --- AGENTS.md | 16 ++--- CONTRIBUTING.md | 5 +- charts/.helmignore | 1 + charts/AGENTS.md | 38 ++++++++++++ charts/CLAUDE.md | 39 +----------- openapi/AGENTS.md | 17 ++++++ openapi/CLAUDE.md | 18 +----- pkg/dao/AGENTS.md | 44 ++++++++++++++ pkg/dao/CLAUDE.md | 45 +------------- pkg/db/AGENTS.md | 57 ++++++++++++++++++ pkg/db/CLAUDE.md | 58 +----------------- pkg/db/README.md | 2 +- pkg/db/migrations/AGENTS.md | 12 ++++ pkg/db/migrations/CLAUDE.md | 13 +--- pkg/errors/AGENTS.md | 54 +++++++++++++++++ pkg/errors/CLAUDE.md | 55 +---------------- pkg/handlers/AGENTS.md | 115 +++++++++++++++++++++++++++++++++++ pkg/handlers/CLAUDE.md | 116 +----------------------------------- pkg/services/AGENTS.md | 41 +++++++++++++ pkg/services/CLAUDE.md | 42 +------------ test/AGENTS.md | 38 ++++++++++++ test/CLAUDE.md | 39 +----------- 22 files changed, 438 insertions(+), 427 deletions(-) create mode 100644 charts/AGENTS.md create mode 100644 openapi/AGENTS.md create mode 100644 pkg/dao/AGENTS.md create mode 100644 pkg/db/AGENTS.md create mode 100644 pkg/db/migrations/AGENTS.md create mode 100644 pkg/errors/AGENTS.md create mode 100644 pkg/handlers/AGENTS.md create mode 100644 pkg/services/AGENTS.md create mode 100644 test/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index 41e08df1..65088722 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,13 +47,13 @@ Quick feedback: `make verify-all`. Full pre-push: `make test-all`. | Topic | Where to look | |---|---| | OpenAPI spec & code generation | [openapi/README.md](openapi/README.md) | -| Handler pipeline & validation | `pkg/handlers/CLAUDE.md` | -| Service interface & status aggregation | `pkg/services/CLAUDE.md` | -| DAO patterns & session access | `pkg/dao/CLAUDE.md` | -| SessionFactory & transactions | `pkg/db/CLAUDE.md` | -| Error constructors & RFC 9457 | `pkg/errors/CLAUDE.md` | -| Test conventions & factories | `test/CLAUDE.md` | -| Helm chart testing | `charts/CLAUDE.md` | +| Handler pipeline & validation | `pkg/handlers/AGENTS.md` | +| Service interface & status aggregation | `pkg/services/AGENTS.md` | +| DAO patterns & session access | `pkg/dao/AGENTS.md` | +| SessionFactory & transactions | `pkg/db/AGENTS.md` | +| Error constructors & RFC 9457 | `pkg/errors/AGENTS.md` | +| Test conventions & factories | `test/AGENTS.md` | +| Helm chart testing | `charts/AGENTS.md` | | Development setup | [docs/development.md](docs/development.md) | | Deployment | [docs/deployment.md](docs/deployment.md) | | Authentication | [docs/authentication.md](docs/authentication.md) | @@ -84,7 +84,7 @@ Quick feedback: `make verify-all`. Full pre-push: `make test-all`. - **`make generate-all` is mandatory** - build and tests fail without it; generated code is gitignored - **`pkg/api/openapi/` is read-only** - never hand-edit, always regenerate -- **Service methods return `*errors.ServiceError`, not stdlib `error`** - use constructor functions from `pkg/errors/errors.go`; see `pkg/errors/CLAUDE.md` for the full reference +- **Service methods return `*errors.ServiceError`, not stdlib `error`** - use constructor functions from `pkg/errors/errors.go`; see `pkg/errors/AGENTS.md` for the full reference - **`apiServer.Close()` severs in-flight requests** - always use `Shutdown(ctx)` with a drain budget; `Close()` is only the force-close fallback after `Shutdown` times out - **Container has no `Close()`** - lifecycle (JWT handler, session factory, OTel) is managed by `pkg/closer` in the composition root, not on `Container` - **Integration tests share a single testcontainer** - `test.NewHelper(t)` initializes once per process via `sync.Once`; the PostgreSQL container, API server, and JWK mock are shared across all tests in the suite diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f96d7c63..e127822f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,7 +79,8 @@ hyperfleet-api/ ├── docs/ # Detailed documentation ├── tools/ # Pinned tool versions (tools/go.mod) ├── Makefile # Build automation and common commands -└── CLAUDE.md # AI agent context (see also subdirectory CLAUDE.md files) +├── AGENTS.md # AI agent context (see also subdirectory AGENTS.md files) +└── CLAUDE.md # Claude Code pointer (@AGENTS.md) ``` ## Testing @@ -297,4 +298,4 @@ HyperFleet API uses semantic versioning and automated releases. - **API Documentation**: See [docs/api-resources.md](docs/api-resources.md) - **Development Guide**: See [docs/development.md](docs/development.md) - **Search & Filtering**: See [docs/search.md](docs/search.md) -- **CLAUDE.md**: AI agent context (main + subdirectories) +- **AGENTS.md**: AI agent context (main + subdirectories) diff --git a/charts/.helmignore b/charts/.helmignore index 369c8fe8..3432555a 100644 --- a/charts/.helmignore +++ b/charts/.helmignore @@ -24,6 +24,7 @@ # helm-docs template (not needed in chart package) README.md.gotmpl # AI agent context +AGENTS.md CLAUDE.md # Parent directory patterns ../.git/ diff --git a/charts/AGENTS.md b/charts/AGENTS.md new file mode 100644 index 00000000..ddcf3845 --- /dev/null +++ b/charts/AGENTS.md @@ -0,0 +1,38 @@ +# charts + +## Verification + +Always validate changes with: `make test-helm` + +This runs lint + template rendering across multiple value combinations (default, external DB, autoscaling, PDB, ServiceMonitor, auth disabled, custom image, full adapter config). + +## Entity Configuration + +Cluster and NodePool (and all other entity types) are configured as entity descriptors in `config.entities`. +Default values include Cluster, NodePool, Channel, Version, and WifConfig. Each entity's required adapters +are declared inline via `required_adapters`. + +## Entity Registration + +Entity descriptors are configured in `config.entities`. Default values include Channel, Version, and WifConfig. +Override with `--set-json 'config.entities=[...]'` for custom entity sets. An empty list disables all +generic entity routes. + +## Database Modes + +Two database configurations supported: +- **Embedded PostgreSQL** (default): `database.postgresql.enabled=true` +- **External database**: `database.postgresql.enabled=false`, `database.external.enabled=true`, `database.external.secretName=...` + +## Chart Structure + +- `Chart.yaml` — chart metadata (apiVersion v2) +- `values.yaml` — default values +- `templates/` — Kubernetes manifests + +## Template Testing + +When modifying templates, test with multiple configurations: +``` +helm template test-release charts/ [additional overrides] +``` diff --git a/charts/CLAUDE.md b/charts/CLAUDE.md index ccac24e6..43c994c2 100644 --- a/charts/CLAUDE.md +++ b/charts/CLAUDE.md @@ -1,38 +1 @@ -# Claude Code Guidelines for Helm Charts - -## Verification - -Always validate changes with: `make test-helm` - -This runs lint + template rendering across multiple value combinations (default, external DB, autoscaling, PDB, ServiceMonitor, auth disabled, custom image, full adapter config). - -## Entity Configuration - -Cluster and NodePool (and all other entity types) are configured as entity descriptors in `config.entities`. -Default values include Cluster, NodePool, Channel, Version, and WifConfig. Each entity's required adapters -are declared inline via `required_adapters`. - -## Entity Registration - -Entity descriptors are configured in `config.entities`. Default values include Channel, Version, and WifConfig. -Override with `--set-json 'config.entities=[...]'` for custom entity sets. An empty list disables all -generic entity routes. - -## Database Modes - -Two database configurations supported: -- **Embedded PostgreSQL** (default): `database.postgresql.enabled=true` -- **External database**: `database.postgresql.enabled=false`, `database.external.enabled=true`, `database.external.secretName=...` - -## Chart Structure - -- `Chart.yaml` — chart metadata (apiVersion v2) -- `values.yaml` — default values -- `templates/` — Kubernetes manifests - -## Template Testing - -When modifying templates, test with multiple configurations: -``` -helm template test-release charts/ [additional overrides] -``` +@AGENTS.md diff --git a/openapi/AGENTS.md b/openapi/AGENTS.md new file mode 100644 index 00000000..f27c2060 --- /dev/null +++ b/openapi/AGENTS.md @@ -0,0 +1,17 @@ +# openapi + +See [README.md](README.md) for the full reference on how schemas are imported, generated, and used for validation. + +## Key Rules + +- **Never edit** `openapi.yaml` or `pkg/api/openapi/openapi.gen.go` — both are overwritten by `make generate`. +- **Never copy** the spec file manually — `make generate` extracts it from the Go module cache automatically. +- **To change the schema**, update TypeSpec in `hyperfleet-api-spec`, publish a new release, bump the version in `go.mod`, then run `make generate-all`. + +## Quick Commands + +```shell +make generate # Extract schema from spec module, then run oapi-codegen +make generate-mocks # Regenerate mock implementations +make generate-all # Both of the above +``` diff --git a/openapi/CLAUDE.md b/openapi/CLAUDE.md index 922552ad..43c994c2 100644 --- a/openapi/CLAUDE.md +++ b/openapi/CLAUDE.md @@ -1,17 +1 @@ -# Claude Code Guidelines for OpenAPI - -See [README.md](README.md) for the full reference on how schemas are imported, generated, and used for validation. - -## Key Rules - -- **Never edit** `openapi.yaml` or `pkg/api/openapi/openapi.gen.go` — both are overwritten by `make generate`. -- **Never copy** the spec file manually — `make generate` extracts it from the Go module cache automatically. -- **To change the schema**, update TypeSpec in `hyperfleet-api-spec`, publish a new release, bump the version in `go.mod`, then run `make generate-all`. - -## Quick Commands - -```shell -make generate # Extract schema from spec module, then run oapi-codegen -make generate-mocks # Regenerate mock implementations -make generate-all # Both of the above -``` +@AGENTS.md diff --git a/pkg/dao/AGENTS.md b/pkg/dao/AGENTS.md new file mode 100644 index 00000000..c9e51700 --- /dev/null +++ b/pkg/dao/AGENTS.md @@ -0,0 +1,44 @@ +# pkg/dao + +## Interface Pattern + +Every DAO defines an interface + concrete `sql*Dao` implementation. + +Reference: `resource.go` — `ResourceDao` interface, `sqlResourceDao` struct + +```go +type ResourceDao interface { ... } +type sqlResourceDao struct { sessionFactory *db.SessionFactory } +func NewResourceDao(sessionFactory *db.SessionFactory) ResourceDao +``` + +## Session Access + +Always get the GORM session from context — never create direct DB connections: +```go +db := d.sessionFactory.New(ctx) +``` +This retrieves the transaction-aware session created by the service's `db.TxRunner.Do` callback. + +## Error Handling + +Return ordinary errors. The service transaction callback rolls back automatically when it returns an error. + +## Patterns + +- Generation increment is handled by the service layer's `Patch` method via `IncrementGeneration()`; `Save()` persists the result +- Use `clause.Associations` carefully — omit on updates to prevent cascading deletes of related records +- All methods accept `context.Context` as first parameter for transaction propagation +- Return stdlib `error` (not `*errors.ServiceError`) — service layer wraps DAO errors into ServiceErrors + +## GenericDao + +`generic.go` provides a chainable query builder: +- `GetInstanceDao(ctx, model)` — creates a new DAO instance for a model +- Chain: `.Preload()`, `.OrderBy()`, `.Joins()`, `.Group()`, `.Where()` +- Execute: `.Fetch(offset, limit, &resultList)`, `.Count(model, &total)` + +## Related AGENTS.md Files + +- `pkg/services/AGENTS.md` — Service layer that consumes DAOs +- `pkg/db/AGENTS.md` — SessionFactory and service-owned transactions diff --git a/pkg/dao/CLAUDE.md b/pkg/dao/CLAUDE.md index b7592f7b..43c994c2 100644 --- a/pkg/dao/CLAUDE.md +++ b/pkg/dao/CLAUDE.md @@ -1,44 +1 @@ -# Claude Code Guidelines for DAOs - -## Interface Pattern - -Every DAO defines an interface + concrete `sql*Dao` implementation. - -Reference: `resource.go` — `ResourceDao` interface, `sqlResourceDao` struct - -```go -type ResourceDao interface { ... } -type sqlResourceDao struct { sessionFactory *db.SessionFactory } -func NewResourceDao(sessionFactory *db.SessionFactory) ResourceDao -``` - -## Session Access - -Always get the GORM session from context — never create direct DB connections: -```go -db := d.sessionFactory.New(ctx) -``` -This retrieves the transaction-aware session created by the service's `db.TxRunner.Do` callback. - -## Error Handling - -Return ordinary errors. The service transaction callback rolls back automatically when it returns an error. - -## Patterns - -- Generation increment is handled by the service layer's `Patch` method via `IncrementGeneration()`; `Save()` persists the result -- Use `clause.Associations` carefully — omit on updates to prevent cascading deletes of related records -- All methods accept `context.Context` as first parameter for transaction propagation -- Return stdlib `error` (not `*errors.ServiceError`) — service layer wraps DAO errors into ServiceErrors - -## GenericDao - -`generic.go` provides a chainable query builder: -- `GetInstanceDao(ctx, model)` — creates a new DAO instance for a model -- Chain: `.Preload()`, `.OrderBy()`, `.Joins()`, `.Group()`, `.Where()` -- Execute: `.Fetch(offset, limit, &resultList)`, `.Count(model, &total)` - -## Related CLAUDE.md Files - -- `pkg/services/CLAUDE.md` — Service layer that consumes DAOs -- `pkg/db/CLAUDE.md` — SessionFactory and service-owned transactions +@AGENTS.md diff --git a/pkg/db/AGENTS.md b/pkg/db/AGENTS.md new file mode 100644 index 00000000..2269b3c3 --- /dev/null +++ b/pkg/db/AGENTS.md @@ -0,0 +1,57 @@ +# pkg/db + +## SessionFactory + +Reference: `session.go` + +``` +type SessionFactory interface { + Init(*config.DatabaseConfig) + DirectDB() *sql.DB + New(ctx context.Context) *gorm.DB // Get active transaction or non-transactional DB session + CheckConnection() error + Close() error + ResetDB() // For testing + NewListener(ctx context.Context, channel string, callback func(id string)) +} +``` + +Always use `New(ctx)` to get a GORM session — never create direct DB connections. When the context contains an active service-owned transaction, `New(ctx)` returns that transaction; otherwise, it returns a non-transactional session. + +## Transactions + +Reference: `transactions.go` + +`TxRunner.Do(ctx, callback)` owns application write transactions. The container +constructs the runner from its session factory; services receive the runner and +own one public mutation boundary each. + +Concrete mutation services open one transaction around their complete atomic operation. +The callback receives the transaction context; return an error to roll back and +return nil to commit before the service returns success. Nested transactions are rejected. +Read-only services do not open transactions. `RequestTimeoutMiddleware` applies request +deadlines to both reads and writes without owning transactions. + +**Mutation Flow**: +1. Request timeout middleware derives a deadline context. +2. The mutation service calls its injected `TxRunner.Do` dependency. +3. The runner begins one transaction and passes a derived transaction context to the callback. +4. Callback errors and panics roll back; a nil callback error commits before service success returns. +5. Nested transaction creation is rejected. Reusable work participates through + private helpers rather than calling another public mutation service. + +**Read Request Flow**: +1. Applies request timeout context +2. Skips transaction creation (performance optimization) +3. DAOs get non-transactional sessions via `SessionFactory.New(ctx)` +4. No transaction overhead + +**Trade-off**: List operations (COUNT + SELECT) may show inconsistent pagination totals under concurrent deletes (cosmetic issue, low probability). + +DAOs return ordinary errors and never decide whether to roll back. Always pass the +callback context to every DAO call, including reads and locking reads. + +## Related AGENTS.md Files + +- `pkg/dao/AGENTS.md` — DAOs that consume SessionFactory +- `pkg/services/AGENTS.md` — Services that depend on transactions diff --git a/pkg/db/CLAUDE.md b/pkg/db/CLAUDE.md index 98b89db2..43c994c2 100644 --- a/pkg/db/CLAUDE.md +++ b/pkg/db/CLAUDE.md @@ -1,57 +1 @@ -# Claude Code Guidelines for Database Layer - -## SessionFactory - -Reference: `session.go` - -``` -type SessionFactory interface { - Init(*config.DatabaseConfig) - DirectDB() *sql.DB - New(ctx context.Context) *gorm.DB // Get active transaction or non-transactional DB session - CheckConnection() error - Close() error - ResetDB() // For testing - NewListener(ctx context.Context, channel string, callback func(id string)) -} -``` - -Always use `New(ctx)` to get a GORM session — never create direct DB connections. When the context contains an active service-owned transaction, `New(ctx)` returns that transaction; otherwise, it returns a non-transactional session. - -## Transactions - -Reference: `transactions.go` - -`TxRunner.Do(ctx, callback)` owns application write transactions. The container -constructs the runner from its session factory; services receive the runner and -own one public mutation boundary each. - -Concrete mutation services open one transaction around their complete atomic operation. -The callback receives the transaction context; return an error to roll back and -return nil to commit before the service returns success. Nested transactions are rejected. -Read-only services do not open transactions. `RequestTimeoutMiddleware` applies request -deadlines to both reads and writes without owning transactions. - -**Mutation Flow**: -1. Request timeout middleware derives a deadline context. -2. The mutation service calls its injected `TxRunner.Do` dependency. -3. The runner begins one transaction and passes a derived transaction context to the callback. -4. Callback errors and panics roll back; a nil callback error commits before service success returns. -5. Nested transaction creation is rejected. Reusable work participates through - private helpers rather than calling another public mutation service. - -**Read Request Flow**: -1. Applies request timeout context -2. Skips transaction creation (performance optimization) -3. DAOs get non-transactional sessions via `SessionFactory.New(ctx)` -4. No transaction overhead - -**Trade-off**: List operations (COUNT + SELECT) may show inconsistent pagination totals under concurrent deletes (cosmetic issue, low probability). - -DAOs return ordinary errors and never decide whether to roll back. Always pass the -callback context to every DAO call, including reads and locking reads. - -## Related CLAUDE.md Files - -- `pkg/dao/CLAUDE.md` — DAOs that consume SessionFactory -- `pkg/services/CLAUDE.md` — Services that depend on transactions +@AGENTS.md diff --git a/pkg/db/README.md b/pkg/db/README.md index 9f079bb2..7b231158 100755 --- a/pkg/db/README.md +++ b/pkg/db/README.md @@ -10,7 +10,7 @@ Create a migration ID based on the time using the YYYYMMDDHHMM format. Example: Your migration's name should be used in the file name and in the function name and should adequately represent the actions your migration is taking. If your migration is doing too much to fit in a name, you should consider creating multiple migrations. -Create a separate file in `pkg/db/migrations/` following the naming schema in place: `_.go`. In the file, you'll create a function that returns a [gormmigrate.Migration](https://gopkg.in/gormigrate.v1/blob/master/gormigrate.go#L37) object with a `gormigrate.Migrate` function defined. **Do not set `Rollback`** — ship a new migration to fix a problem instead of rolling one back (see `pkg/db/migrations/CLAUDE.md`). +Create a separate file in `pkg/db/migrations/` following the naming schema in place: `_.go`. In the file, you'll create a function that returns a [gormmigrate.Migration](https://gopkg.in/gormigrate.v1/blob/master/gormigrate.go#L37) object with a `gormigrate.Migrate` function defined. **Do not set `Rollback`** — ship a new migration to fix a problem instead of rolling one back (see `pkg/db/migrations/AGENTS.md`). Add the function you created in the separate file to the `migrations` list in `pkg/db/migrations.go`. diff --git a/pkg/db/migrations/AGENTS.md b/pkg/db/migrations/AGENTS.md new file mode 100644 index 00000000..a4a925e8 --- /dev/null +++ b/pkg/db/migrations/AGENTS.md @@ -0,0 +1,12 @@ +# pkg/db/migrations + +## No Rollbacks — Fix Forward + +Never set the `Rollback` field on a `gormigrate.Migration`. If a migration has a problem, ship a new +migration to correct it instead of rolling back the old one. Rollbacks can destroy data (e.g. +`DROP COLUMN`) that was legitimately written after the migration ran, so they are not used here even +though gormigrate supports them. + +## Other Rules + +See `migration_structs.go` for ID formatting, backwards-compatibility, and type re-declaration rules. diff --git a/pkg/db/migrations/CLAUDE.md b/pkg/db/migrations/CLAUDE.md index 969eb98d..43c994c2 100644 --- a/pkg/db/migrations/CLAUDE.md +++ b/pkg/db/migrations/CLAUDE.md @@ -1,12 +1 @@ -# Claude Code Guidelines for Migrations - -## No Rollbacks — Fix Forward - -Never set the `Rollback` field on a `gormigrate.Migration`. If a migration has a problem, ship a new -migration to correct it instead of rolling back the old one. Rollbacks can destroy data (e.g. -`DROP COLUMN`) that was legitimately written after the migration ran, so they are not used here even -though gormigrate supports them. - -## Other Rules - -See `migration_structs.go` for ID formatting, backwards-compatibility, and type re-declaration rules. +@AGENTS.md diff --git a/pkg/errors/AGENTS.md b/pkg/errors/AGENTS.md new file mode 100644 index 00000000..1ccf6534 --- /dev/null +++ b/pkg/errors/AGENTS.md @@ -0,0 +1,54 @@ +# pkg/errors + +Reference: `errors.go` + +## ServiceError Type + +``` +type ServiceError struct { + RFC9457Code string // HYPERFLEET-CAT-NUM format + Type string // RFC 9457 type URI + Title string // Short human-readable summary + Reason string // Context-specific detail (supports fmt.Sprintf) + HTTPCode int // HTTP status code + Details []ValidationDetail // Field-level validation errors +} +``` + +## Error Code Categories + +Format: `HYPERFLEET-[CAT]-[NUM]` + +| Category | Code | Meaning | +|---|---|---| +| VAL | Validation | Request validation failures | +| AUT | Auth | Authentication errors | +| NTF | NotFound | Resource not found | +| CNF | Conflict | Resource conflicts | +| LMT | RateLimit | Rate limiting | +| INT | Internal | Internal server errors | +| SVC | Service | Upstream service errors | + +## Constructor Functions + +- `NotFound(reason, values...)` — 404 +- `GeneralError(reason, values...)` — 500 +- `Validation(reason, values...)` — 400 +- `ValidationWithDetails(reason, details)` — 400 with field-level errors +- `MalformedRequest(reason, values...)` — 400 (unparseable body) +- `BadRequest(reason, values...)` — 400 +- `Conflict(reason, values...)` — 409 +- `FailedToParseSearch(reason, values...)` — 400 +- `New(code, reason, values...)` — Custom error from registered code + +## RFC 9457 Problem Details + +`AsProblemDetails(instance, traceID)` converts to `openapi.ProblemDetails` with: +- `type` URI, `title`, `detail` (reason), `status` (HTTP code) +- `code` (HYPERFLEET-CAT-NUM), `timestamp`, `traceId` (optional — omitted when empty) +- `validationErrors` array for field-level details + +## Related AGENTS.md Files + +- `pkg/handlers/AGENTS.md` — Handlers that return these errors +- `pkg/services/AGENTS.md` — Services that construct these errors diff --git a/pkg/errors/CLAUDE.md b/pkg/errors/CLAUDE.md index c835c646..43c994c2 100644 --- a/pkg/errors/CLAUDE.md +++ b/pkg/errors/CLAUDE.md @@ -1,54 +1 @@ -# Claude Code Guidelines for Errors - -Reference: `errors.go` - -## ServiceError Type - -``` -type ServiceError struct { - RFC9457Code string // HYPERFLEET-CAT-NUM format - Type string // RFC 9457 type URI - Title string // Short human-readable summary - Reason string // Context-specific detail (supports fmt.Sprintf) - HTTPCode int // HTTP status code - Details []ValidationDetail // Field-level validation errors -} -``` - -## Error Code Categories - -Format: `HYPERFLEET-[CAT]-[NUM]` - -| Category | Code | Meaning | -|---|---|---| -| VAL | Validation | Request validation failures | -| AUT | Auth | Authentication errors | -| NTF | NotFound | Resource not found | -| CNF | Conflict | Resource conflicts | -| LMT | RateLimit | Rate limiting | -| INT | Internal | Internal server errors | -| SVC | Service | Upstream service errors | - -## Constructor Functions - -- `NotFound(reason, values...)` — 404 -- `GeneralError(reason, values...)` — 500 -- `Validation(reason, values...)` — 400 -- `ValidationWithDetails(reason, details)` — 400 with field-level errors -- `MalformedRequest(reason, values...)` — 400 (unparseable body) -- `BadRequest(reason, values...)` — 400 -- `Conflict(reason, values...)` — 409 -- `FailedToParseSearch(reason, values...)` — 400 -- `New(code, reason, values...)` — Custom error from registered code - -## RFC 9457 Problem Details - -`AsProblemDetails(instance, traceID)` converts to `openapi.Error` with: -- `type` URI, `title`, `detail` (reason), `status` (HTTP code) -- `code` (HYPERFLEET-CAT-NUM), `timestamp`, `traceId` -- `validationErrors` array for field-level details - -## Related CLAUDE.md Files - -- `pkg/handlers/CLAUDE.md` — Handlers that return these errors -- `pkg/services/CLAUDE.md` — Services that construct these errors +@AGENTS.md diff --git a/pkg/handlers/AGENTS.md b/pkg/handlers/AGENTS.md new file mode 100644 index 00000000..ba56664e --- /dev/null +++ b/pkg/handlers/AGENTS.md @@ -0,0 +1,115 @@ +# pkg/handlers + +## Request Processing + +Handlers follow a standard pipeline: + +1. **Decode and validate** — Use `decodeAndValidate(r, &req, validateFuncs)` to unmarshal request body and run validation + - Pass `"strict"` as 4th parameter for PATCH requests only to reject unknown/immutable fields via `DisallowUnknownFields()` + - Validation functions return `*errors.ServiceError` on failure +2. **Convert** — Use `presenters.Convert*()` to convert request types to internal models +3. **Call service** — Pass converted models to service layer methods +4. **Present** — Use `presenters.Present*()` to convert internal models to response types +5. **Write response** — Use `writeJSONResponse(w, r, statusCode, data)` or `handleError(r, w, err)` + +## Handler Types + +- **ResourceHandler** — Handles both flat (`/api/hyperfleet/v1/clusters`) and owner-nested (`/api/hyperfleet/v1/clusters/{parent_id}/nodepools`) routes for a single entity kind + - Extract parent_id to determine route type - `r.PathValue("parent_id")` + - See struct comment for safety invariant (plugins must register correctly) +- **RootResourceHandler** — Handles kind-agnostic routes (`/api/hyperfleet/v1/resources`, `/api/hyperfleet/v1/resources/{id}`) + - Resolves entity kind dynamically via `registry.Get(kind)` + - Handles adapter status operations (`/api/hyperfleet/v1/resources/{id}/statuses`) + +## Validation Patterns + +Validation functions close over the request struct and return `func() *errors.ServiceError`: + +```go +validateFuncs := []validate{ + validateKind(&req, "Kind", "kind", expectedKind), + validateName(&req, "Name", "name", minLen, maxLen), + validateSpec(&req, "Spec", "spec"), + validateLabels(&req, "Labels"), +} +``` + +Common validators (in `validation.go`): +- `validateKind(req, fieldName, jsonName, expectedKind)` — Ensures `kind` matches descriptor +- `validateName(req, fieldName, jsonName, minLen, maxLen)` — Name length constraints +- `validateSpec(req, fieldName, jsonName)` — Spec is non-nil +- `validateLabels(req, fieldName)` — Labels follow RFC 1123 rules +- `validatePatchRequest(req)` — At least one field is set +- `validateNotEmpty(req, fieldName, jsonName)` — String field is non-empty +- `validateMaxLength(req, fieldName, jsonName, maxLen)` — String length constraint + +## Owner Verification + +For nested routes, verify the child belongs to the parent: + +```go +if err := h.checkOwnership(r, id); err != nil { + handleError(r, w, err) + return +} +``` + +This is a no-op for flat routes (no `parent_id` in path). + +## Error Handling + +Use `handleError(r, w, err)` to write RFC 9457 Problem Details responses: +- Logs errors with structured fields (trace ID, error code, HTTP status) +- Automatically extracts trace ID from request context +- Sets `instance` to the request path +- Error constructor functions (from `pkg/errors`) + + +## Path Parameters + +Extract route parameters with r.PathValue + +```go +id := r.PathValue("id") +// Extract parent_id +if parentID := r.PathValue("parent_id"); parentID != "" { + if _, svcErr := h.resourceService.GetByOwner(ctx, h.descriptor.Kind, id, parentID); svcErr != nil { + handleError(r, w, svcErr) + return + } +} +``` + +## Field Filtering + +Apply `?fields=` query parameter filtering: + +```go +result, svcErr := applyFieldFilter(r, presenters.PresentResource(resource)) +if svcErr != nil { + handleError(r, w, svcErr) + return +} + +``` + +For list responses: +```go +if listArgs.Fields != nil { + filtered, svcErr := presenters.SliceFilter(listArgs.Fields, result) + if svcErr != nil { + handleError(r, w, svcErr) + return // REQUIRED — omitting this causes a double-write + } + writeJSONResponse(w, r, http.StatusOK, filtered) + return // REQUIRED — prevents falling through to the unfiltered write below +} +writeJSONResponse(w, r, http.StatusOK, result) +``` + +## Related Files + +- `pkg/services/AGENTS.md` — Service layer patterns +- `pkg/errors/AGENTS.md` — Error constructors and codes +- `helpers.go` — `writeJSONResponse`,`decodeAndValidate`, `handleError`, `applyFieldFilter` implementations +- `validation.go` — Validator functions (`validateKind`, `validateName`, `validateSpec`, etc.) diff --git a/pkg/handlers/CLAUDE.md b/pkg/handlers/CLAUDE.md index 1d5d733c..43c994c2 100644 --- a/pkg/handlers/CLAUDE.md +++ b/pkg/handlers/CLAUDE.md @@ -1,115 +1 @@ -# Claude Code Guidelines for Handlers - -## Request Processing - -Handlers follow a standard pipeline: - -1. **Decode and validate** — Use `decodeAndValidate(r, &req, validateFuncs)` to unmarshal request body and run validation - - Pass `"strict"` as 4th parameter for PATCH requests only to reject unknown/immutable fields via `DisallowUnknownFields()` - - Validation functions return `*errors.ServiceError` on failure -2. **Convert** — Use `presenters.Convert*()` to convert request types to internal models -3. **Call service** — Pass converted models to service layer methods -4. **Present** — Use `presenters.Present*()` to convert internal models to response types -5. **Write response** — Use `writeJSONResponse(w, r, statusCode, data)` or `handleError(r, w, err)` - -## Handler Types - -- **ResourceHandler** — Handles both flat (`/api/hyperfleet/v1/clusters`) and owner-nested (`/api/hyperfleet/v1/clusters/{parent_id}/nodepools`) routes for a single entity kind - - Extract parent_id to determine route type - `r.PathValue("parent_id")` - - See struct comment for safety invariant (plugins must register correctly) -- **RootResourceHandler** — Handles kind-agnostic routes (`/api/hyperfleet/v1/resources`, `/api/hyperfleet/v1/resources/{id}`) - - Resolves entity kind dynamically via `registry.Get(kind)` - - Handles adapter status operations (`/api/hyperfleet/v1/resources/{id}/statuses`) - -## Validation Patterns - -Validation functions close over the request struct and return `func() *errors.ServiceError`: - -```go -validateFuncs := []validate{ - validateKind(&req, "Kind", "kind", expectedKind), - validateName(&req, "Name", "name", minLen, maxLen), - validateSpec(&req, "Spec", "spec"), - validateLabels(&req, "Labels"), -} -``` - -Common validators (in `validation.go`): -- `validateKind(req, fieldName, jsonName, expectedKind)` — Ensures `kind` matches descriptor -- `validateName(req, fieldName, jsonName, minLen, maxLen)` — Name length constraints -- `validateSpec(req, fieldName, jsonName)` — Spec is non-nil -- `validateLabels(req, fieldName)` — Labels follow RFC 1123 rules -- `validatePatchRequest(req)` — At least one field is set -- `validateNotEmpty(req, fieldName, jsonName)` — String field is non-empty -- `validateMaxLength(req, fieldName, jsonName, maxLen)` — String length constraint - -## Owner Verification - -For nested routes, verify the child belongs to the parent: - -```go -if err := h.checkOwnership(r, id); err != nil { - handleError(r, w, err) - return -} -``` - -This is a no-op for flat routes (no `parent_id` in path). - -## Error Handling - -Use `handleError(r, w, err)` to write RFC 9457 Problem Details responses: -- Logs errors with structured fields (trace ID, error code, HTTP status) -- Automatically extracts trace ID from request context -- Sets `instance` to the request path -- Error constructor functions (from `pkg/errors`) - - -## Path Parameters - -Extract route parameters with r.PathValue - -```go -id := r.PathValue("id") -// Extract parent_id -if parentID := r.PathValue("parent_id"); parentID != "" { - if _, svcErr := h.resourceService.GetByOwner(ctx, h.descriptor.Kind, id, parentID); svcErr != nil { - handleError(r, w, svcErr) - return - } -} -``` - -## Field Filtering - -Apply `?fields=` query parameter filtering: - -```go -result, svcErr := applyFieldFilter(r, presenters.PresentResource(resource)) -if svcErr != nil { - handleError(r, w, svcErr) - return -} - -``` - -For list responses: -```go -if listArgs.Fields != nil { - filtered, svcErr := presenters.SliceFilter(listArgs.Fields, result) - if svcErr != nil { - handleError(r, w, svcErr) - return // REQUIRED — omitting this causes a double-write - } - writeJSONResponse(w, r, http.StatusOK, filtered) - return // REQUIRED — prevents falling through to the unfiltered write below -} -writeJSONResponse(w, r, http.StatusOK, result) -``` - -## Related Files - -- `pkg/services/CLAUDE.md` — Service layer patterns -- `pkg/errors/CLAUDE.md` — Error constructors and codes -- `helpers.go` — `writeJSONResponse`,`decodeAndValidate`, `handleError`, `applyFieldFilter` implementations -- `validation.go` — Validator functions (`validateKind`, `validateName`, `validateSpec`, etc.) +@AGENTS.md diff --git a/pkg/services/AGENTS.md b/pkg/services/AGENTS.md new file mode 100644 index 00000000..5d9b8d39 --- /dev/null +++ b/pkg/services/AGENTS.md @@ -0,0 +1,41 @@ +# pkg/services + +## Interface Pattern + +Every service defines an interface + concrete `sql*Service` implementation. + +Reference: `resource.go` — `ResourceService` interface, `sqlResourceService` struct + +```go +type ResourceService interface { ... } +type sqlResourceService struct { ... } +func NewResourceService(resourceDao, resourceLabelDao, adapterStatusDao, resourceConditionDao, generic) ResourceService +``` + +## Conventions + +- All methods return `*errors.ServiceError` (from `pkg/errors/`), never stdlib `error` +- Constructor injection: DAOs and config passed to `New*Service()` constructor +- Mutation services receive `db.TxRunner` and open one transaction with `Do` at their public boundary. Every dependent DAO call must use the callback context. Reconciliation outcomes are emitted only after `Do` succeeds. +- Compile-time interface check: `var _ ResourceService = &sqlResourceService{}` +- Mock generation: add `//go:generate mockgen` directive, then `make generate-mocks` + +## Status Aggregation + +`UpdateStatusFromAdapters()` in `resource.go` synthesizes two top-level conditions: +- **Available**: True if all required adapters report `Available=True` (any generation) +- **Ready**: True if all adapters report `Available=True` AND `observed_generation` matches current generation + +`ProcessAdapterStatus()` validates mandatory conditions (`Available`, `Applied`, `Health`) before persisting. Rejects `Available=Unknown` on subsequent reports (only allowed on first report). + +## GenericService + +`generic.go` provides `List()` with pagination, search, and ordering. +- `ListArguments` has Page, Size, Search, Order, Fields, Preloads +- Default ordering: `created_time desc` + +## Related AGENTS.md Files + +- `pkg/handlers/AGENTS.md` — Handler pipeline that calls services +- `pkg/dao/AGENTS.md` — DAO layer that services depend on +- `pkg/errors/AGENTS.md` — ServiceError type returned by all methods diff --git a/pkg/services/CLAUDE.md b/pkg/services/CLAUDE.md index 788fd2f5..43c994c2 100644 --- a/pkg/services/CLAUDE.md +++ b/pkg/services/CLAUDE.md @@ -1,41 +1 @@ -# Claude Code Guidelines for Services - -## Interface Pattern - -Every service defines an interface + concrete `sql*Service` implementation. - -Reference: `resource.go` — `ResourceService` interface, `sqlResourceService` struct - -```go -type ResourceService interface { ... } -type sqlResourceService struct { ... } -func NewResourceService(resourceDao, resourceLabelDao, adapterStatusDao, resourceConditionDao, generic) ResourceService -``` - -## Conventions - -- All methods return `*errors.ServiceError` (from `pkg/errors/`), never stdlib `error` -- Constructor injection: DAOs and config passed to `New*Service()` constructor -- Mutation services receive `db.TxRunner` and open one transaction with `Do` at their public boundary. Every dependent DAO call must use the callback context. Reconciliation outcomes are emitted only after `Do` succeeds. -- Compile-time interface check: `var _ ResourceService = &sqlResourceService{}` -- Mock generation: add `//go:generate mockgen` directive, then `make generate-mocks` - -## Status Aggregation - -`UpdateStatusFromAdapters()` in `resource.go` synthesizes two top-level conditions: -- **Available**: True if all required adapters report `Available=True` (any generation) -- **Ready**: True if all adapters report `Available=True` AND `observed_generation` matches current generation - -`ProcessAdapterStatus()` validates mandatory conditions (`Available`, `Applied`, `Health`) before persisting. Rejects `Available=Unknown` on subsequent reports (only allowed on first report). - -## GenericService - -`generic.go` provides `List()` with pagination, search, and ordering. -- `ListArguments` has Page, Size, Search, Order, Fields, Preloads -- Default ordering: `created_time desc` - -## Related CLAUDE.md Files - -- `pkg/handlers/CLAUDE.md` — Handler pipeline that calls services -- `pkg/dao/CLAUDE.md` — DAO layer that services depend on -- `pkg/errors/CLAUDE.md` — ServiceError type returned by all methods +@AGENTS.md diff --git a/test/AGENTS.md b/test/AGENTS.md new file mode 100644 index 00000000..7eba01cc --- /dev/null +++ b/test/AGENTS.md @@ -0,0 +1,38 @@ +# test + +## Unit Tests + +- Run: `make test` +- Use Gomega assertions: `. "github.com/onsi/gomega"` with `RegisterTestingT(t)` +- Mock generation: `make generate-mocks` — never write mocks manually +- Mocks use `go.uber.org/mock/gomock` + +## Integration Tests + +- Run: `make test-integration` (sets `TESTCONTAINERS_RYUK_DISABLED=true`) +- Located in `integration/` +- Testcontainers auto-creates isolated PostgreSQL per test suite — no external DB needed +- Setup: `test.RegisterIntegration(t)` returns `(helper, httpClient)` +- HTTP assertions use Resty client + +## Test Factories + +Reference: `factories/` + +- `factories.NewCluster(id)` — creates a Cluster resource via service layer +- `factories.NewClusterList(name, count)` — creates multiple Cluster resources +- `NewClusterWithStatus(f, dbFactory, id, isAvailable, isReconciled)` — Cluster resource with conditions +- `NewClusterWithLabels(f, dbFactory, id, labels)` — Cluster resource with labels +- ID generation: RFC 9562 UUID v7 (36-char lowercase format with hyphens, time-ordered with millisecond precision) +- Kubernetes usage: Lowercase UUIDs are DNS-1123 compliant and can be used directly as K8s resource names + +## TestMain Setup + +`integration/integration_test.go` — `TestMain(m *testing.M)`: +- Schema validation: `TestMain` auto-sets `HYPERFLEET_SERVER_OPENAPI_SCHEMA_PATH` to `test/validation-schema.yaml` (permissive, all registered entities) if present, else `openapi/openapi.yaml` +- 45-second timeout safeguard for CI (prevents hung Prow jobs) + +## Key Environment Variables + +- `TESTCONTAINERS_RYUK_DISABLED=true` — required for testcontainers in CI +- `HYPERFLEET_SERVER_OPENAPI_SCHEMA_PATH` — path to OpenAPI schema for spec validation (auto-set by `TestMain` to `test/validation-schema.yaml`) diff --git a/test/CLAUDE.md b/test/CLAUDE.md index a15a3f56..43c994c2 100644 --- a/test/CLAUDE.md +++ b/test/CLAUDE.md @@ -1,38 +1 @@ -# Claude Code Guidelines for Tests - -## Unit Tests - -- Run: `make test` -- Use Gomega assertions: `. "github.com/onsi/gomega"` with `RegisterTestingT(t)` -- Mock generation: `make generate-mocks` — never write mocks manually -- Mocks use `go.uber.org/mock/gomock` - -## Integration Tests - -- Run: `make test-integration` (sets `TESTCONTAINERS_RYUK_DISABLED=true`) -- Located in `integration/` -- Testcontainers auto-creates isolated PostgreSQL per test suite — no external DB needed -- Setup: `test.RegisterIntegration(t)` returns `(helper, httpClient)` -- HTTP assertions use Resty client - -## Test Factories - -Reference: `factories/` - -- `factories.NewCluster(id)` — creates a Cluster resource via service layer -- `factories.NewClusterList(name, count)` — creates multiple Cluster resources -- `NewClusterWithStatus(f, dbFactory, id, isAvailable, isReconciled)` — Cluster resource with conditions -- `NewClusterWithLabels(f, dbFactory, id, labels)` — Cluster resource with labels -- ID generation: RFC4122 UUID v7 (36-char lowercase format with hyphens, time-ordered with millisecond precision) -- Kubernetes usage: Lowercase UUIDs are DNS-1123 compliant and can be used directly as K8s resource names - -## TestMain Setup - -`integration/integration_test.go` — `TestMain(m *testing.M)`: -- Schema validation: `TestMain` auto-sets `HYPERFLEET_SERVER_OPENAPI_SCHEMA_PATH` to `test/validation-schema.yaml` (permissive, all registered entities) if present, else `openapi/openapi.yaml` -- 45-second timeout safeguard for CI (prevents hung Prow jobs) - -## Key Environment Variables - -- `TESTCONTAINERS_RYUK_DISABLED=true` — required for testcontainers in CI -- `HYPERFLEET_SERVER_OPENAPI_SCHEMA_PATH` — path to OpenAPI schema for spec validation (auto-set by `TestMain` to `test/validation-schema.yaml`) +@AGENTS.md From b945b752c02f724979b190b12b14934a33f10b2b Mon Sep 17 00:00:00 2001 From: Rafael Benevides Date: Tue, 20 Oct 2026 21:08:26 -0300 Subject: [PATCH 37/41] HYPERFLEET-1812 - chore: align frozen pre-commit refs (#403) ### What Align the existing hook revisions and migrate every remote ref to the frozen format (`rev: # frozen: `), preserving the existing hook selection. - HyperFleet hooks v0.3.0: annotated tag SHA `d9aab6d723c9246bd2f63a982bf1d9cf0805b3a4`, resolving to the validated commit `0c42dbf10d4087ac8c788b19b9d29afd7bccdd4a`. - LeakTK v0.3.12 and pre-commit-hooks v6.0.0, both full-SHA pinned. ### Why Renovate recognizes release/digest pairs through `# frozen:`; the previous `# v...` comments do not activate SHA-preserving extraction. This addresses the SHA-to-tag rewrite seen in hyperfleet-api commit 312f4fa without disabling dependency automation. See [Renovate frozen digest support](https://github.com/renovatebot/renovate/pull/36693). [HYPERFLEET-1812](https://redhat.atlassian.net/browse/HYPERFLEET-1812); coordinated with the four open adoption PRs in [HYPERFLEET-1805](https://redhat.atlassian.net/browse/HYPERFLEET-1805). No dependency on those merges or a new hooks release. ### Validation - `GOTOOLCHAIN=go1.26.0 pre-commit run --all-files`: passed. - `pre-commit validate-config`: passed; valid commit message accepted and invalid message rejected through the commit-msg hook. - Renovate 44.71.0 native extraction recognizes every remote ref as a version plus digest; native replacement tests preserve SHA pinning for both release and digest-only updates. - `go test -p 2 ./pkg/... ./cmd/...`: passed. ### Deployment verification The production GitOps definition for `kflux-prd-rh02` inherits [this MintMaker base](https://github.com/redhat-appstudio/infra-deployments/blob/main/components/mintmaker/production/base/kustomization.yaml), pinning image `bab7f6da3c2178e3d8e3446b4cdd8ca8e5a9a3a1`. [Its Dockerfile](https://github.com/konflux-ci/mintmaker-renovate-image/blob/bab7f6da3c2178e3d8e3446b4cdd8ca8e5a9a3a1/Dockerfile) fixes Renovate 44.71.0-rpm / fork revision `6a2542ca879a3e0945f3090c6c3507de5345cbf7`, which contains frozen extraction support. Tests used upstream 44.71.0, not a live MintMaker run. Confirm rollout and the first automated frozen update before closing the ticket. No auto-approval or automerge policy is introduced. ### Baseline hygiene The full hook run found preexisting trailing whitespace/EOF issues. Only those automatic fixes are included outside the config/guide; a whitespace-insensitive diff confirms no additional semantic changes. Files: - `LICENSE` [HYPERFLEET-1812]: https://redhat.atlassian.net/browse/HYPERFLEET-1812?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ [HYPERFLEET-1805]: https://redhat.atlassian.net/browse/HYPERFLEET-1805?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ ### Additional test results - Integration: `go test -p 1 -timeout 4m ./test/integration` passed (Docker-backed). - Working tree remains clean after the checks. --- .pre-commit-config.yaml | 6 +++--- LICENSE | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 298bd7b4..d65dfa83 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,13 +3,13 @@ default_install_hook_types: [pre-commit, commit-msg] repos: # Secret scanning (VPN-free, open-source alternative to rh-pre-commit) - repo: https://github.com/leaktk/leaktk - rev: e62a495260dc1e396fa586e51bbe1355f6fc7d9d # v0.3.3 + rev: c6f5a11329736a8dc1551a84603f117bd9ed498e # frozen: v0.3.12 hooks: - id: leaktk.git.pre-commit # HyperFleet code quality hooks - repo: https://github.com/openshift-hyperfleet/hyperfleet-hooks - rev: 7f5119208e5cdee3f0c688881b6a4b803095f203 # v0.2.1 + rev: d9aab6d723c9246bd2f63a982bf1d9cf0805b3a4 # frozen: v0.3.0 hooks: - id: hyperfleet-commitlint stages: [commit-msg] @@ -19,7 +19,7 @@ repos: # File hygiene - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 # v6.0.0 + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer diff --git a/LICENSE b/LICENSE index 1c9ac719..c6499060 100755 --- a/LICENSE +++ b/LICENSE @@ -199,4 +199,4 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and - limitations under the License. \ No newline at end of file + limitations under the License. From 89d5a80a1deda6da59134d8e287048c47a0aca24 Mon Sep 17 00:00:00 2001 From: Dmitrii Andreev Date: Wed, 21 Oct 2026 04:53:55 -0500 Subject: [PATCH 38/41] HYPERFLEET-1790 - chore: Remove the make install that does nothing from the Dockerfile (#404) ## Why The Dockerfile ran `dnf install -y make` in the builder stage, with a comment saying UBI9 go-toolset doesn't include make. `ubi9/go-toolset:9.8-1792010711` already ships `make-4.3-8.el9`, so the step installed nothing. It still refreshed the dnf repo metadata over the network on every build. [HYPERFLEET-1790](https://redhat.atlassian.net/browse/HYPERFLEET-1790). ## What changed - Removed the `dnf install -y make && dnf clean all` line. - Kept the `USER root` block that creates `/build` and hands it to user 1001, and updated its comment to say only that. Built the builder stage locally: `make build` and both FIPS checks pass. The container image standard is updated in openshift-hyperfleet/architecture#251. [HYPERFLEET-1790]: https://redhat.atlassian.net/browse/HYPERFLEET-1790?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- Dockerfile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8744bea4..caa2bee0 100755 --- a/Dockerfile +++ b/Dockerfile @@ -7,9 +7,8 @@ ARG GIT_DIRTY="" ARG BUILD_DATE="" ARG APP_VERSION="0.0.0-dev" -# Install make as root (UBI9 go-toolset doesn't include it), then switch back to non-root. +# Create /build as root and hand it to the non-root build user. USER root -RUN dnf install -y make && dnf clean all WORKDIR /build RUN chown 1001:0 /build USER 1001 From 6fd2c6fc1a15eea6dbefc301ea58a1fedf5d0620 Mon Sep 17 00:00:00 2001 From: Rafael Benevides Date: Wed, 21 Oct 2026 11:19:23 -0300 Subject: [PATCH 39/41] HYPERFLEET-1815 - fix: preserve Markdown hard line breaks (#405) ### What Add `args: [--markdown-linebreak-ext=md]` to the existing `trailing-whitespace` hook. One line only; preserve hook selection, existing configuration and full-SHA frozen refs. No hook implementation or release change is needed. ### Why Two trailing spaces can encode a Markdown hard line break. The default fixer removes them; this argument preserves them in `.md` files while retaining ordinary whitespace cleanup elsewhere. [HYPERFLEET-1815](https://redhat.atlassian.net/browse/HYPERFLEET-1815). Architecture is handled separately in [architecture#250](https://github.com/openshift-hyperfleet/architecture/pull/250). ### Cleanup audit Audited the merged baseline cleanup in #403, comparing commit `b945b752c02f` with its first parent and checking current content. No Markdown prose hard breaks were lost in that change. The rollout audit found no lost rendered hard breaks outside the already-corrected architecture PR, so this PR does not reintroduce cosmetic whitespace, code-block whitespace or blank lines. ### Validation - `GOTOOLCHAIN=go1.26.0 pre-commit run --all-files`: passed twice without additional changes. - Isolated fixture using this exact config and pinned hook: Markdown two-space hard break preserved (renders `
`), `.txt` trailing whitespace removed, second run clean. - `pre-commit validate-config`, YAML lint, and `git diff --check`: passed. - Parsed-config comparison confirms the only change is the added argument; all refs, hooks and other options are preserved. No runtime source changed. Application integration/live E2E suites were not rerun; the hook itself was exercised end to end with the fixture above. No Markdown files are modified. [HYPERFLEET-1815]: https://redhat.atlassian.net/browse/HYPERFLEET-1815?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d65dfa83..55f1b91e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,5 +22,6 @@ repos: rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 hooks: - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] - id: end-of-file-fixer - id: check-added-large-files From 138c1b61f6e1b56a9a81b990c1cdec43ba329606 Mon Sep 17 00:00:00 2001 From: Rafael Benevides Date: Wed, 21 Oct 2026 16:11:41 -0300 Subject: [PATCH 40/41] HYPERFLEET-1819 - ci: scan tracked content for secrets on PR checkouts --- .github/workflows/secret-scan.yml | 128 ++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 .github/workflows/secret-scan.yml diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 00000000..851692a9 --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,128 @@ +# Secret scanning for clean CI checkouts. +# +# The leaktk.git.pre-commit hook only scans staged changes and ignores the +# filenames passed by pre-commit, so it is a no-op on a clean CI checkout: +# a green "pre-commit run --all-files" is not evidence that PR content was +# scanned. This job closes that gap. +# +# Scope: the working tree of the fresh pull_request checkout, which is the +# tracked content at the merge HEAD. It is not a scan of git history and it +# does not depend on the index being populated. +# +# Behavior: fail-closed. Findings, scanner errors, missing, invalid or +# non-correlated responses and timeouts all fail the job. Scanner stdout and +# stderr are captured and never printed; findings are reported with rule, +# path and line only. +# +# The LeakTK revision is the one pinned by this repository in +# .pre-commit-config.yaml (full commit SHA required; tag-only pins are +# rejected). This is a secondary, non-required gate. +name: secret-scan + +on: + pull_request: + +permissions: {} + +concurrency: + group: secret-scan-${{ github.ref }} + cancel-in-progress: true + +jobs: + secret-scan: + permissions: + contents: read + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + CGO_ENABLED: "0" + LEAKTK_ID: ci-secret-scan + LEAKTK_BIN: ${{ runner.temp }}/leaktk-bin/leaktk + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: false + + - name: Resolve the pinned LeakTK revision + run: | + set -euo pipefail + LEAKTK_REV=$(awk ' + /^[[:space:]]*-[[:space:]]*repo:[[:space:]]*https:\/\/github\.com\/leaktk\/leaktk[[:space:]]*$/ { in_repo = 1; next } + in_repo && /^[[:space:]]*rev:/ { print $2; exit } + in_repo && /^[[:space:]]*-[[:space:]]*repo:/ { in_repo = 0 } + ' .pre-commit-config.yaml | tr -d '"') + if [ -z "${LEAKTK_REV:-}" ]; then + echo "::error::No leaktk rev found in .pre-commit-config.yaml. Adopting the secret scan in a repository without LeakTK requires an explicit scope decision; refusing to scan silently." + exit 1 + fi + if ! printf '%s' "$LEAKTK_REV" | grep -qE '^[0-9a-f]{40}$'; then + echo "::error::The leaktk rev in .pre-commit-config.yaml must be a full 40-character commit SHA; got: ${LEAKTK_REV}. Tag-only pins are rejected." + exit 1 + fi + echo "LeakTK revision from .pre-commit-config.yaml: ${LEAKTK_REV}" + echo "LEAKTK_REV=${LEAKTK_REV}" >> "$GITHUB_ENV" + + - name: Install LeakTK at the pinned revision + run: | + set -euo pipefail + mkdir -p "$(dirname "$LEAKTK_BIN")" + GOBIN="$(dirname "$LEAKTK_BIN")" go install github.com/leaktk/leaktk@"$LEAKTK_REV" + "$LEAKTK_BIN" version || true + + - name: Scan tracked content for secrets + run: | + set -euo pipefail + + HEAD_SHA=$(git rev-parse HEAD) + TRACKED_FILES=$(git ls-files | wc -l | tr -d ' ') + RESPONSE_FILE="$RUNNER_TEMP/leaktk-response.json" + STDERR_FILE="$RUNNER_TEMP/leaktk-stderr.log" + + set +e + timeout 480 "$LEAKTK_BIN" scan \ + --kind Files \ + --leak-exit-code 1 \ + --format json \ + --id "$LEAKTK_ID" \ + . >"$RESPONSE_FILE" 2>"$STDERR_FILE" + SCAN_STATUS=$? + set -e + + # Fail closed unless the scanner returns a valid response for this request. + if ! jq -e --arg id "$LEAKTK_ID" '.kind == "ScanResults" and .request_id == $id' "$RESPONSE_FILE" >/dev/null 2>&1; then + echo "::error::LeakTK did not return a valid response for this scan (exit status: ${SCAN_STATUS}); failing closed. Raw scanner output is withheld." + exit 1 + fi + + if jq -e '.error != null' "$RESPONSE_FILE" >/dev/null; then + echo "::error::LeakTK scan failed: $(jq -r '.error.message' "$RESPONSE_FILE") (code $(jq -r '.error.code' "$RESPONSE_FILE"))" + exit 1 + fi + + if ! jq -e '(.results | type) == "array"' "$RESPONSE_FILE" >/dev/null; then + echo "::error::LeakTK response has no results array; failing closed." + exit 1 + fi + + FINDINGS=$(jq '.results | length' "$RESPONSE_FILE") + + if [ "$FINDINGS" -gt 0 ]; then + echo "::error::Secret scan found ${FINDINGS} potential secret(s) in the tracked content at HEAD ${HEAD_SHA}." + echo "Redacted findings (secret values are withheld):" + jq -r '.results[] | "- \(.rule.description) at \(.location.path):\(.location.start.line) (rule id: \(.rule.id))"' "$RESPONSE_FILE" + exit 1 + fi + + if [ "$SCAN_STATUS" -ne 0 ]; then + echo "::error::LeakTK exited with status ${SCAN_STATUS} without reporting findings or errors; failing closed." + exit 1 + fi + + echo "Secret scan clean: ${TRACKED_FILES} tracked files at HEAD ${HEAD_SHA} scanned with LeakTK ${LEAKTK_REV} (kind=Files on the fresh checkout)." From 05a350c34d67faafa7e8e1d3d7b90159ae479319 Mon Sep 17 00:00:00 2001 From: Rafael Benevides Date: Wed, 21 Oct 2026 16:34:03 -0300 Subject: [PATCH 41/41] HYPERFLEET-1819 - fix: drop runner context from job env, use RUNNER_TEMP in steps --- .github/workflows/secret-scan.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index 851692a9..b4281b88 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -37,7 +37,6 @@ jobs: env: CGO_ENABLED: "0" LEAKTK_ID: ci-secret-scan - LEAKTK_BIN: ${{ runner.temp }}/leaktk-bin/leaktk steps: - name: Check out source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -72,6 +71,7 @@ jobs: - name: Install LeakTK at the pinned revision run: | set -euo pipefail + LEAKTK_BIN="$RUNNER_TEMP/leaktk-bin/leaktk" mkdir -p "$(dirname "$LEAKTK_BIN")" GOBIN="$(dirname "$LEAKTK_BIN")" go install github.com/leaktk/leaktk@"$LEAKTK_REV" "$LEAKTK_BIN" version || true @@ -79,6 +79,7 @@ jobs: - name: Scan tracked content for secrets run: | set -euo pipefail + LEAKTK_BIN="$RUNNER_TEMP/leaktk-bin/leaktk" HEAD_SHA=$(git rev-parse HEAD) TRACKED_FILES=$(git ls-files | wc -l | tr -d ' ')