From 7ce0f47d309e24c00ee4840104af25bb23706e94 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Sun, 28 Jun 2026 18:10:37 +0200 Subject: [PATCH 01/75] spec(telemetry): INFP-589 Phase 1 telemetry collection specification Add additive, backwards-compatible telemetry payload metrics spec: accounts.active/groups, branches.active, database.node_count.corenode, and the activity_24h object (logins, unique_logins, webhook success/failure). Producer-only; user_node_count (IFC-2825) and Phase 2 items out of scope. Co-Authored-By: Claude Opus 4.8 --- .specify/feature.json | 2 +- .../checklists/requirements.md | 42 +++ .../telemetry-collection-infp-589/spec.md | 285 ++++++++++++++++++ 3 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 dev/specs/telemetry-collection-infp-589/checklists/requirements.md create mode 100644 dev/specs/telemetry-collection-infp-589/spec.md diff --git a/.specify/feature.json b/.specify/feature.json index 1be33c1f1c0..5f239a2420e 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/001-entities-arch-migration" + "feature_directory": "specs/telemetry-collection-infp-589" } diff --git a/dev/specs/telemetry-collection-infp-589/checklists/requirements.md b/dev/specs/telemetry-collection-infp-589/checklists/requirements.md new file mode 100644 index 00000000000..c8895f202ed --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/checklists/requirements.md @@ -0,0 +1,42 @@ +# Specification Quality Checklist: Phase 1 Telemetry Collection + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-28 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Field names (`accounts.active`, `activity_24h.*`, `database.node_count.corenode`, + etc.) are retained in the spec because they constitute the externally-observable + payload **contract** — the deliverable itself — not implementation detail. The + spec deliberately avoids prescribing how each value is computed (no class names, + query mechanics, or module paths); those live in plan.md. +- The handoff supplied a detailed PRD with grounded code pointers. Those pointers + are intentionally deferred to the planning phase rather than embedded here. +- FR-004 is intentionally absent (blocked, out of scope) — numbering matches the + source PRD's FR list, which skips FR-004 in this feature. diff --git a/dev/specs/telemetry-collection-infp-589/spec.md b/dev/specs/telemetry-collection-infp-589/spec.md new file mode 100644 index 00000000000..fda0c6ce46f --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/spec.md @@ -0,0 +1,285 @@ +# Feature Specification: Phase 1 Telemetry Collection + +**Feature Branch**: `telemetry-collection-infp-589` + +**Created**: 2026-06-28 + +**Status**: Draft + +**Input**: User description: "Phase 1 telemetry collection (epic IFC-2789, idea INFP-589). Extend Infrahub's daily telemetry payload with additive, backwards-compatible metrics for 1.11. Producer-only: add fields to the emitted/stored payload; dashboard rendering is out of scope (INFP-550 / SA-184). EXCLUDE user_node_count (blocked on a product decision — IFC-2825)." + +## Overview + +Infrahub emits an anonymous telemetry payload on a daily schedule. Today that +payload captures a handful of coarse counts (total branches, raw vertex count, +unwindowed Prefect event tallies). The product and data teams cannot answer +basic adoption questions from it — how many accounts are active, how the +deployment is scaling in domain terms, or what happened in the last day. + +This feature extends the daily payload with a set of **additive, +backwards-compatible** metrics so the receiving data mart gains usable adoption +and scaling signals. It is **producer-only**: it changes what Infrahub emits and +stores, not how anyone visualizes it. Dashboard work (INFP-550 / SA-184) is a +separate, downstream effort. + +The work is deliberately scoped as Phase 1 of a larger telemetry roadmap (epic +IFC-2789). Phase 2 metrics (licensing, token usage, generator/transformation +adoption, branch lifetime, PR governance, CLI/MCP/Sync adoption, GraphQL/REST +metrics) and the blocked `user_node_count` metric are explicitly **not** in +scope here. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Daily activity signal over a trailing 24h window (Priority: P1) + +As the OpsMill data team, I need each daily telemetry payload to carry an +`activity_24h` object describing what happened in the trailing 24 hours — +logins, unique logins, and webhook successes/failures — so I can observe usage +trends per deployment instead of meaningless lifetime totals. + +**Why this priority**: This is the enabler. It introduces the new windowed +event-query path, the `activity_24h` object, the `payload_format` bump, and the +graceful-degradation behavior that every other metric depends on. Without it the +other metrics have no consistent failure contract and no precedent for windowed +queries. It also delivers the highest-value signal: real daily activity. + +**Independent Test**: Seed a deployment with login and webhook-process events, +some inside the trailing 24h window and some outside it, then trigger the daily +gather. The emitted payload contains an `activity_24h` object whose counts +reflect exactly the in-window events and ignore the out-of-window ones. + +**Acceptance Scenarios**: + +1. **Given** a deployment with login events both inside and outside the trailing + 24h window, **When** the daily telemetry payload is gathered, **Then** + `activity_24h.logins` equals the count of in-window login events only. +2. **Given** logins from a set of distinct accounts within the window (some + accounts logging in multiple times), **When** the payload is gathered, + **Then** `activity_24h.unique_logins` equals the number of distinct accounts, + not the number of login events. +3. **Given** webhook-process runs in the last 24h with a mix of successes and + failures, **When** the payload is gathered, **Then** + `activity_24h.webhooks_fired_success` and `activity_24h.webhooks_fired_failure` + reflect exactly the in-window successful and failed runs respectively. +4. **Given** a deployment with zero activity in the window, **When** the payload + is gathered, **Then** each `activity_24h` count is `0` (not `null`, not + absent). + +--- + +### User Story 2 - Account and branch adoption metrics (Priority: P2) + +As the OpsMill data team, I need the payload to report the number of active +accounts, account groups, and currently-open non-system branches, so I can +gauge real adoption per deployment. + +**Why this priority**: These are high-value adoption signals computed through +the standard branch-safe count path. They depend on the graceful-degradation +contract established in Story 1 but are otherwise independent. + +**Independent Test**: Seed a deployment with a known mix of active/inactive +accounts, account groups, and open/closed/system branches, trigger the gather, +and assert each reported count matches the seeded fixture exactly. + +**Acceptance Scenarios**: + +1. **Given** a deployment with active and non-active accounts, **When** the + payload is gathered, **Then** `accounts.active` equals the count of accounts + whose status is active. +2. **Given** a known number of account groups, **When** the payload is gathered, + **Then** `accounts.groups` equals that count. +3. **Given** open branches including the default and the global system branch, + **When** the payload is gathered, **Then** `branches.active` counts the open + branches while excluding the default branch and the global system branch. +4. **Given** the existing `branches.total` field, **When** the payload is + gathered, **Then** `branches.total` is unchanged in meaning, type, and name. + +--- + +### User Story 3 - Branch-correct scaling metric for managed nodes (Priority: P2) + +As the OpsMill data team, I need a node count that reflects the number of +schema-managed nodes (the `CoreNode` generic) computed the same way the product +counts nodes, so I can measure how a deployment scales in domain terms — distinct +from the raw vertex total that includes internal graph bookkeeping. + +**Why this priority**: Scaling is a primary telemetry question and the raw vertex +total is misleading for it. This metric must be computed through the +branch-safe, temporal-correct count path rather than a raw label count. + +**Independent Test**: Build a fixture with a known number of managed nodes, +independently compute the expected count, trigger the gather, and assert +`database.node_count.corenode` matches the independently-computed value exactly +(±0). + +**Acceptance Scenarios**: + +1. **Given** a deployment with a known number of managed (`CoreNode`-generic) + nodes, **When** the payload is gathered, **Then** + `database.node_count.corenode` equals that count exactly. +2. **Given** the existing `database.node_count.total` raw-vertex field, **When** + the payload is gathered, **Then** `database.node_count.total` is unchanged in + meaning, type, and name, and the distinction between the raw total and the + managed-node count is documented. + +--- + +### User Story 4 - Resilient payload that never silently drops everything (Priority: P1) + +As the OpsMill data team, I need the daily payload to keep arriving even when one +metric source fails, with a clear convention distinguishing "source failed" from +"genuinely zero", so I can trust field presence and interpret nulls correctly. + +**Why this priority**: This is the reliability contract that makes the data +usable. Without it, one failing query could drop the whole payload, or a `null` +could ambiguously mean either "failed" or "none", corrupting trend analysis. + +**Independent Test**: Force one metric's source to fail while leaving the others +healthy, trigger the gather, and assert the failed metric is `null`, every other +field is populated, and the payload is still sent/stored. + +**Acceptance Scenarios**: + +1. **Given** one metric source that raises an error during gathering, **When** + the payload is gathered, **Then** that metric's field is `null`, all other + fields are populated, and the payload is still emitted and stored. +2. **Given** a metric source that succeeds but legitimately has nothing to count, + **When** the payload is gathered, **Then** that metric's field is `0`, not + `null`. +3. **Given** any version of the new payload, **When** it is emitted, **Then** the + `payload_format` identifier reflects the new payload version. + +--- + +### Edge Cases + +- **Event-retention leakage**: The trailing-24h window is far shorter than the + underlying event retention (7 days for events, 90 days for webhook flow runs), + so a correct window must never include retained-but-out-of-window records. +- **Best-effort event counts**: Event dispatch can drop records, so activity + counts are a trend signal, not a billing-grade exact count. This is acceptable + and must be documented; success criteria for event metrics are framed around + windowing correctness, not against an external ground truth. +- **Default and system branches**: `branches.active` must exclude the default + branch and the global system branch; only genuinely open, non-system branches + count. +- **Existing-field protection**: No existing field may change meaning, type, or + name. Deprecation is allowed; removal is not. +- **Consumer compatibility**: A `payload_format` bump plus new fields must be + tolerated by a forward-compatible consumer that ignores unknown fields. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The payload MUST report `accounts.active` as the count of accounts + whose status is active, computed through the standard branch-safe count path. + [IFC-2822] +- **FR-002**: The payload MUST report `accounts.groups` as the count of account + groups. [IFC-2822] +- **FR-003**: The payload MUST report `database.node_count.corenode` as the count + of managed (`CoreNode`-generic) nodes, computed through the branch-safe, + temporal-correct count path (not a raw vertex/label count). [IFC-2821] +- **FR-005**: The payload MUST report `branches.active` as the count of open, + non-system branches, excluding the default branch and the global system + branch. [IFC-2822] +- **FR-006**: The payload MUST report `activity_24h.webhooks_fired_success` and + `activity_24h.webhooks_fired_failure` as the counts of successful and failed + webhook-process runs over the trailing 24h. [IFC-2824] +- **FR-007**: The feature MUST add a NEW trailing-24h-windowed event-query path + that feeds `activity_24h`, WITHOUT modifying the existing unwindowed event + tally output, and MUST advance the `payload_format` identifier. [IFC-2820] +- **FR-008**: The payload MUST report `activity_24h.logins` (count of login + events over the trailing 24h) and `activity_24h.unique_logins` (distinct + accounts over the same window). [IFC-2823] +- **FR-009**: The feature MUST NOT change the existing raw-vertex + `database.node_count.total` field, and MUST document the distinction between + the raw total, the managed-node (`corenode`) count, and the (future, blocked) + user-node count. [IFC-2821] +- **FR-010**: When a metric's source fails, that field MUST be set to `null` + while the rest of the payload is still emitted and stored; when a source + succeeds with nothing to count, the field MUST be `0`, not `null`. [IFC-2820] +- **FR-011**: All changes MUST be additive. No existing field may change its + meaning, type, or name. Deprecation is permitted; removal is not. + +### Governance Requirement + +- **GR-001**: Before shipping, the payload contract change (the `payload_format` + bump and the new fields) MUST be confirmed compatible with the receiving end + (the cloud telemetry processor and the downstream data mart) — specifically + that the receiver tolerates the format bump and ignores unknown fields. Because + every change is additive, a forward-compatible consumer keeps working; this is + a confirmation gate, not a code dependency. + +### Key Entities *(include if feature involves data)* + +- **Telemetry payload**: The daily anonymous data structure Infrahub emits and + stores. Carries a `payload_format` version identifier and nested sub-objects + (`accounts`, `branches`, `database`, and the new `activity_24h`). +- **`activity_24h` object**: A new sub-object holding trailing-24h activity + counts: `logins`, `unique_logins`, `webhooks_fired_success`, + `webhooks_fired_failure`. +- **Account**: A user account with a status (active vs. non-active) used for + `accounts.active`. +- **Account group**: A grouping of accounts, counted for `accounts.groups`. +- **Branch**: A line of change. The default branch and the global system branch + are excluded from `branches.active`. +- **Login event**: An event emitted after authentication and stored in the event + system (not in the graph database). The source for `activity_24h.logins` and + `unique_logins`. +- **Webhook-process run**: An execution of the webhook delivery flow, with a + success/failure outcome, counted for the `activity_24h` webhook fields. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: All in-scope fields are present on 100% of daily telemetry runs; a + field is `null` only when its source genuinely failed, never when the source + succeeded with nothing to count. +- **SC-002**: Event-derived metrics reflect exactly the trailing 24h, with no + leakage from records retained but outside the window — verified with fixtures + containing both in-window and out-of-window records. +- **SC-003**: `database.node_count.corenode` matches an independently-computed + fixture count exactly (±0). +- **SC-004**: No existing telemetry field changes meaning, type, or name across + this release; the `payload_format` identifier is advanced and a + forward-compatible consumer that ignores unknown fields continues to parse the + payload (confirmed with the receiving team per GR-001). + +## Assumptions + +- The telemetry feature is producer-only; consuming/visualizing the new fields + (dashboards, INFP-550 / SA-184) is out of scope and handled downstream. +- Login activity is sourced from the event system (events are emitted after + authentication and stored there); the graph database holds no last-login + timestamp, so a windowed event query is the correct and only source. +- The trailing-24h window is comfortably shorter than the underlying retention + windows (7-day event retention, 90-day webhook flow-run retention), so all + in-window records are available at gather time. +- Activity counts are best-effort trend signals (event dispatch can drop), not + billing-grade exact figures; correctness is judged on windowing behavior, not + against an external ground truth. +- Counts are gathered on the default branch through the standard branch-safe + count path, consistent with how the product computes them elsewhere. +- The "graceful degradation" contract (per-metric isolation, `null` on failure, + `0` on genuine empty) applies uniformly to every in-scope metric. + +## Out of Scope + +- `database.node_count.user` (FR-004 / IFC-2825) — blocked on a product decision + about which namespaces to include; explicitly excluded from this feature. +- All Phase 2 telemetry items: licensing (cores/RAM), distinct API-token usage, + generators/transformations adoption, branch lifetime, PR governance, + CLI/MCP/Sync adoption, and GraphQL/REST metrics. +- Dashboard rendering and any consumer-side visualization (INFP-550 / SA-184). +- Redefining or altering the existing `database.node_count.total` raw-vertex + metric. +- Persisting logins or any last-login state in the graph database. + +## Dependencies + +- **Receiving-end confirmation (GR-001)**: A confirmation gate with the + cloud-processor and data-mart owners that the additive payload change is + tolerated before shipping. Not a code dependency (the change is additive), but + a release gate. From a49d93131bfedb776346766453ce5fa5292f6e11 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Sun, 28 Jun 2026 18:15:47 +0200 Subject: [PATCH 02/75] plan(telemetry): INFP-589 Phase 1 implementation plan + design artifacts Add plan.md, research.md, data-model.md, contracts/telemetry-payload.md, quickstart.md. Grounded in existing telemetry module: NodeManager.count for branch/temporal-correct counts, new windowed Prefect path for activity_24h, per-metric graceful-degradation (null on failure, 0 on empty), payload_format bump. Constitution check passes (no new entities/deps). Co-Authored-By: Claude Opus 4.8 --- .../contracts/telemetry-payload.md | 101 +++++++++++++ .../data-model.md | 77 ++++++++++ .../telemetry-collection-infp-589/plan.md | 122 ++++++++++++++++ .../quickstart.md | 83 +++++++++++ .../telemetry-collection-infp-589/research.md | 136 ++++++++++++++++++ 5 files changed, 519 insertions(+) create mode 100644 dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md create mode 100644 dev/specs/telemetry-collection-infp-589/data-model.md create mode 100644 dev/specs/telemetry-collection-infp-589/plan.md create mode 100644 dev/specs/telemetry-collection-infp-589/quickstart.md create mode 100644 dev/specs/telemetry-collection-infp-589/research.md diff --git a/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md b/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md new file mode 100644 index 00000000000..a476150a0e6 --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md @@ -0,0 +1,101 @@ +# Contract: Telemetry Payload (Phase 1 additions) + +The contract is the **daily telemetry payload** Infrahub emits to the remote endpoint and +stores locally. This document defines the additive changes. The consumer (cloud processor + +data mart) is forward-compatible: it ignores unknown fields, so additive changes are safe. + +## Envelope (unchanged shape, bumped version) + +```jsonc +{ + "kind": "community", + "payload_format": "20260628", // BUMPED from "20250318" + "data": { /* TelemetryData — see below */ }, + "checksum": "" +} +``` + +- `payload_format` advances to `"20260628"` (FR-007). Convention: `YYYYMMDD`. +- A consumer keying on `payload_format` must tolerate the new value (GR-001 confirmation gate). + +## `data` additions + +```jsonc +{ + // ... all existing fields unchanged ... + + "branches": { + "total": 12, // unchanged + "active": 4 // NEW: open non-system branches; int | null + }, + + "accounts": { // NEW object + "active": 7, // int | null + "groups": 3 // int | null + }, + + "database": { + // ... + "node_count": { + "total": 154233, // unchanged: raw vertex total + "corenode": 4821, // NEW key: managed-node count; int | null + // ... existing graph-label keys unchanged ... + } + }, + + "activity_24h": { // NEW object — trailing 24h window + "logins": 19, // int | null + "unique_logins": 6, // int | null + "webhooks_fired_success": 41, // int | null + "webhooks_fired_failure": 2 // int | null + } +} +``` + +## Field semantics + +| Path | Source | Window | Empty | Failure | +|------|--------|--------|-------|---------| +| `branches.active` | registry: `branch.values()` minus `is_default`/`is_global` | current | `0` | `null` | +| `accounts.active` | `NodeManager.count(CoreAccount, status=ACTIVE)` | current | `0` | `null` | +| `accounts.groups` | `NodeManager.count(CoreAccountGroup)` | current | `0` | `null` | +| `database.node_count.corenode` | `NodeManager.count(CoreNode)` | current | `0` | `null` | +| `activity_24h.logins` | Prefect `account.logged_in` events, windowed | trailing 24h | `0` | `null` | +| `activity_24h.unique_logins` | Prefect count-by-resource on login events, windowed | trailing 24h | `0` | `null` | +| `activity_24h.webhooks_fired_success` | Prefect `webhook-process` flow runs, `COMPLETED` | trailing 24h | `0` | `null` | +| `activity_24h.webhooks_fired_failure` | Prefect `webhook-process` flow runs, `FAILED`/`CRASHED`/`TIMEDOUT` | trailing 24h | `0` | `null` | + +## Invariants the consumer can rely on + +1. **Additive only.** No existing field changes name, type, or meaning. (`node_count` value + type widens to allow `null` only on the new `corenode` key; existing keys stay `int`.) +2. **`null` means failure, `0` means empty.** A field is `null` iff its source raised during + gathering; `0` iff the source succeeded with nothing to count. (FR-010, SC-001) +3. **Payload always ships.** One failing metric never drops the payload; the rest is gathered, + stored, and sent. (SC-001) +4. **24h fields are exact to the window.** No leakage from retained-but-out-of-window records. + (SC-002) +5. **`corenode` is branch/temporal-correct** and matches an independent fixture count exactly. + (SC-003) + +## Internal interface contracts (producer side) + +These are the new/changed producer-side function contracts (full signatures land in `tasks.md`): + +- `gather_account_information(db) -> TelemetryAccountData` — both fields via `NodeManager.count`, + each degradable to `null`. +- `gather_database_information(db) -> TelemetryDatabaseData` — extended to set + `node_count["corenode"]` via `NodeManager.count(CoreNode)`, degradable to `null`, without + touching `node_count["total"]` or the graph-label keys. +- `gather_activity_24h(client) -> TelemetryActivity24hData` — windowed login count, windowed + unique-login (count-by-resource) count, and `webhook-process` flow-run success/failure split, + each field degradable to `null`. +- `gather_prefect_events(client)` — **UNCHANGED** (existing unwindowed tally; FR-007). +- A degradation helper in `tasks.py`: runs a metric coroutine, returns its value or `null` on + exception (logged). Serves all new metrics. + +## Governance (GR-001) + +Before shipping: confirm the cloud processor and BigQuery/Metabase data mart tolerate the +`payload_format` bump and ignore unknown fields. Additive design means a forward-compatible +consumer keeps working; this is a release gate, not a code dependency. diff --git a/dev/specs/telemetry-collection-infp-589/data-model.md b/dev/specs/telemetry-collection-infp-589/data-model.md new file mode 100644 index 00000000000..575ee0419e8 --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/data-model.md @@ -0,0 +1,77 @@ +# Phase 1 Data Model: Phase 1 Telemetry Collection + +This feature adds no database schema entities. The "data model" here is the **telemetry +payload model** (Pydantic models in `backend/infrahub/telemetry/models.py`). All changes are +additive. + +## New models + +### `TelemetryAccountData` + +| Field | Type | Meaning | Empty | Failure | +|----------|--------------|----------------------------------------------------|-------|---------| +| `active` | `int \| None`| Count of `CoreAccount` with `status == ACTIVE` | `0` | `null` | +| `groups` | `int \| None`| Count of `CoreAccountGroup` | `0` | `null` | + +Counts via `NodeManager.count` on the default branch (branch/temporal-correct). + +### `TelemetryActivity24hData` + +| Field | Type | Meaning | Empty | Failure | +|---------------------------|---------------|---------------------------------------------------------|-------|---------| +| `logins` | `int \| None` | `account.logged_in` events in the trailing 24h | `0` | `null` | +| `unique_logins` | `int \| None` | Distinct `account_id` among those logins (same window) | `0` | `null` | +| `webhooks_fired_success` | `int \| None` | `webhook-process` flow runs in 24h ending `COMPLETED` | `0` | `null` | +| `webhooks_fired_failure` | `int \| None` | `webhook-process` flow runs in 24h ending `FAILED`/`CRASHED`/`TIMEDOUT` | `0` | `null` | + +Each field is isolated: one failing source nulls only its own field. + +## Extended models + +### `TelemetryBranchData` (extended) + +| Field | Type | Status | Meaning | +|----------|---------------|------------|--------------------------------------------------------------| +| `total` | `int` | unchanged | Existing total branch count (`len(registry.branch)`). | +| `active` | `int \| None` | **new** | Open non-system branches (exclude `main` / `-global-`). `0` empty, `null` failure. | + +### `TelemetryDatabaseData.node_count` (value type widened) + +| Key | Type widening | Status | Meaning | +|----------------|------------------------------|------------|-------------------------------------------------------------| +| `node_count` | `dict[str, int]` → `dict[str, int \| None]` | **widened** | Holds existing keys (`total`, graph labels) + new `corenode`. | +| `…["total"]` | `int` | unchanged | Raw vertex total (`count_nodes(db)`). | +| `…["corenode"]`| `int \| None` | **new key**| Managed-node count via `NodeManager.count(CoreNode)`. `0` empty, `null` failure. | + +Widening is additive in practice: existing keys are always populated `int`; only `corenode` +may be `null`. No existing key changes meaning or name (FR-011). + +### `TelemetryData` (root, extended) + +| Field | Type | Status | +|----------------|----------------------------|---------| +| `accounts` | `TelemetryAccountData` | **new** | +| `activity_24h` | `TelemetryActivity24hData` | **new** | +| `branches` | `TelemetryBranchData` | extended (see above) | +| `database` | `TelemetryDatabaseData` | extended (node_count) | +| *(all other existing fields)* | unchanged | unchanged | + +The two new root objects are always present; per-metric nullability lives on their fields, so +a whole-source failure surfaces as nulled fields, never a missing object (SC-001). + +## Validation / invariants + +- **Additive**: no existing field renamed, retyped (except the documented `node_count` value + widening), or removed (FR-011). +- **null vs 0**: `null` ⇔ source raised; `0` ⇔ source succeeded with nothing to count + (FR-010, SC-001). +- **Windowing**: event/flow-run metrics count only records whose occurrence/start is within + the trailing 24h (SC-002). +- **Branch-correct**: `corenode`, `accounts.*` computed on the default branch via + `NodeManager.count` (Constitution II); `corenode` must equal an independently-computed + fixture exactly (SC-003). + +## Out of model (this phase) + +- `database.node_count["user"]` — blocked (IFC-2825), not added. +- No changes to `TelemetryPrefectData.events` (the existing unwindowed tally). diff --git a/dev/specs/telemetry-collection-infp-589/plan.md b/dev/specs/telemetry-collection-infp-589/plan.md new file mode 100644 index 00000000000..c9fa3f70228 --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/plan.md @@ -0,0 +1,122 @@ +# Implementation Plan: Phase 1 Telemetry Collection + +**Branch**: `telemetry-collection-infp-589` | **Date**: 2026-06-28 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `specs/telemetry-collection-infp-589/spec.md` + +## Summary + +Extend Infrahub's daily anonymous telemetry payload with additive, backwards-compatible +metrics: account adoption (`accounts.active`, `accounts.groups`), open-branch count +(`branches.active`), a branch/temporal-correct managed-node count +(`database.node_count.corenode`), and a new trailing-24h `activity_24h` object +(`logins`, `unique_logins`, `webhooks_fired_success`, `webhooks_fired_failure`). + +Technical approach: add the new fields to the existing Pydantic payload models; add gather +functions that use the standard branch-safe `NodeManager.count` path for node-based metrics +and a NEW windowed Prefect query path for event/flow-run metrics (the existing unwindowed +`gather_prefect_events` output is left untouched). Bump `TELEMETRY_VERSION`. Introduce a +per-metric graceful-degradation wrapper so a single failing source yields `null` for that +field while the rest of the payload is still gathered, stored, and sent; a source that +succeeds with nothing to count yields `0`. + +## Technical Context + +**Language/Version**: Python 3.14 + +**Primary Dependencies**: Pydantic 2.12 (payload models), Prefect (flow orchestration + +events/flow-run API), Neo4j driver 6.2 (via `InfrahubDatabase` / `NodeManager`) + +**Storage**: Neo4j (node/branch counts via `NodeManager.count`); Prefect event & flow-run +store (24h activity metrics — the graph DB is not an event log, per ADR 0002) + +**Testing**: pytest 9.0 — component tests (TestContainers) for DB-backed counts and the +gather flow; unit tests for windowing/degradation logic where no DB is required + +**Target Platform**: Linux server (Infrahub backend task-worker) + +**Project Type**: Web service backend (single backend package; no frontend work this phase) + +**Performance Goals**: Daily batch job; each metric is a single aggregate query +(`NodeManager.count` / Prefect count-by). No per-node iteration, no N+1. + +**Constraints**: Additive only — no existing field changes meaning/type/name. Per-metric +isolation: one failing source must not drop the payload. Event metrics must reflect exactly +the trailing 24h with no retention leakage. + +**Scale/Scope**: ~9 new payload fields across 2 new sub-models + 2 extended sub-models; +~3 new gather functions; 1 degradation helper; 1 constant bump. Producer-only. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Gate | Status | +|-----------|------|--------| +| I. Schema-Driven Integrity | No schema/generated-file edits; reads through schema-aware `NodeManager`. | ✅ Pass — read-only, no schema changes. | +| II. Branch-Safe by Default | Node/account counts run on the default branch through the standard `NodeManager.count` path (branch + temporal filters applied). Raw `count_nodes(label=...)` is explicitly avoided for `corenode`. | ✅ Pass. | +| III. Type Safety & Explicit Contracts | New Pydantic models with explicit `int \| None` fields; gather functions fully type-hinted; `str \| None` style. | ✅ Pass. | +| IV. Test Discipline | Component tests for SC-001/002/003; tests mirror source under `tests/component/telemetry/` and `tests/unit/telemetry/`; adapter/fixture patterns, no `unittest.mock`. | ✅ Pass. | +| V. Query Performance & Efficiency | Each metric = one aggregate query; no N+1; windowed Prefect queries bounded to 24h. | ✅ Pass. | +| VI. Security & Input Boundaries | No new user input; telemetry is anonymous and opt-out-aware; no secrets; existing endpoint/auth untouched. | ✅ Pass. | +| VII. Simplicity & Maintainability | No new entities, no new dependencies; one small degradation helper justified by ≥2 callers; follows existing `telemetry/*.py` gather-module pattern. | ✅ Pass. | + +**Initial gate: PASS.** No violations; Complexity Tracking left empty. + +## Project Structure + +### Documentation (this feature) + +```text +specs/telemetry-collection-infp-589/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ +│ └── telemetry-payload.md # Payload contract (new/changed fields + degradation rules) +└── tasks.md # Phase 2 output (/speckit-tasks — not created here) +``` + +### Source Code (repository root) + +```text +backend/infrahub/telemetry/ +├── constants.py # BUMP TELEMETRY_VERSION +├── models.py # ADD TelemetryAccountData, TelemetryActivity24hData; +│ # EXTEND TelemetryBranchData (+active), node_count value type; +│ # ADD accounts + activity_24h to TelemetryData +├── tasks.py # ADD gather_account_information, branches.active wiring, +│ # degradation helper; wire accounts/activity_24h into +│ # gather_anonymous_telemetry_data +├── database.py # ADD node_count["corenode"] via NodeManager.count (degradable) +└── task_manager.py # ADD windowed event path + gather_activity_24h + # (leave gather_prefect_events untouched) + +backend/tests/ +├── component/telemetry/ +│ ├── test_datatabase.py # EXTEND: corenode count correctness (SC-003) +│ ├── test_task_manager.py # EXTEND: 24h windowing (SC-002), unique_logins, webhooks +│ └── test_tasks.py # NEW: gather flow presence + degradation (SC-001) +└── unit/telemetry/ + └── test_degradation.py # NEW: null-on-failure vs 0-on-empty helper logic +``` + +**Structure Decision**: Single backend package, extending the existing `telemetry/` module. +Each metric source keeps its home: DB counts in `database.py`, Prefect/event metrics in +`task_manager.py`, account counts + orchestration + degradation in `tasks.py`. This mirrors +the current separation and adds no new top-level structure (Constitution VII). + +## Complexity Tracking + +> No constitution violations. Section intentionally empty. + +## Phase Notes + +- **Governance gate (GR-001)**: Before merge/release, confirm with the cloud-processor and + data-mart owners that the `payload_format` bump + new fields are tolerated (consumer + ignores unknown fields). This is a release checklist item carried into `tasks.md`, not a + code dependency — every change is additive. +- **Parked**: `database.node_count.user` (IFC-2825) is out of scope; the contract doc notes + the three-way distinction (`total` raw / `corenode` managed / `user` future) without + implementing `user`. diff --git a/dev/specs/telemetry-collection-infp-589/quickstart.md b/dev/specs/telemetry-collection-infp-589/quickstart.md new file mode 100644 index 00000000000..5597e51b243 --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/quickstart.md @@ -0,0 +1,83 @@ +# Quickstart / Validation Guide: Phase 1 Telemetry Collection + +How to validate the feature end-to-end. Implementation details live in `tasks.md`; this is a +run/verify guide. + +## Prerequisites + +```bash +uv sync --all-groups +export DOCKER_HOST=unix://$HOME/.docker/run/docker.sock # component tests need the user docker socket +``` + +## 1. Unit: degradation helper (fast, no DB) + +Validates `null`-on-failure vs `0`-on-empty in isolation (SC-001). + +```bash +uv run pytest backend/tests/unit/telemetry/test_degradation.py -q +``` + +Expected: a failing metric coroutine yields `None`; a succeeding-but-empty coroutine yields `0`. + +## 2. Component: managed-node count exactness (SC-003) + +```bash +uv run pytest backend/tests/component/telemetry/test_datatabase.py -q +``` + +Expected: with a fixture of N managed nodes seeded via existing schema helpers, +`node_count["corenode"]` equals N exactly (±0), and `node_count["total"]` (raw vertices) is +unchanged and ≥ N. + +## 3. Component: 24h windowing + activity metrics (SC-002) + +```bash +uv run pytest backend/tests/component/telemetry/test_task_manager.py -q +``` + +Expected: +- `account.logged_in` events seeded inside the trailing 24h are counted; out-of-window events + are not. +- `unique_logins` collapses multiple logins from the same account to one. +- `webhook-process` flow runs in-window split correctly into success/failure; the existing + unwindowed `prefect.events.*` output is unchanged. + +## 4. Component: full gather flow presence + degradation (SC-001) + +```bash +uv run pytest backend/tests/component/telemetry/test_tasks.py -q +``` + +Expected: +- The gathered payload contains `accounts.{active,groups}`, `branches.active`, + `database.node_count.corenode`, and `activity_24h.{logins,unique_logins,webhooks_fired_success,webhooks_fired_failure}`. +- When one source is made to fail (injected failing collaborator/fixture — no `unittest.mock`), + that field is `null`, every other field is populated, and the payload is still built/stored. + +## 5. Whole telemetry suite + lint + +```bash +uv run pytest backend/tests/unit/telemetry backend/tests/component/telemetry -q +uv run invoke format lint +``` + +## 6. Manual payload inspection (optional) + +Trigger the daily flow in a dev stack and inspect a stored snapshot to confirm +`payload_format == "20260628"` and the new fields are present with sensible values. + +## 7. Governance gate (GR-001) — before merge/release + +Confirm with the cloud-processor owner and the data-mart owner that the `payload_format` bump +and new fields are tolerated (consumer ignores unknown fields). Record the confirmation on the +PR / tracking ticket. No code change depends on it (additive), but it gates release. + +## Success criteria mapping + +| Criterion | Validated by | +|-----------|--------------| +| SC-001 (presence + null-vs-0) | Steps 1, 4 | +| SC-002 (exact 24h window) | Step 3 | +| SC-003 (corenode exact) | Step 2 | +| SC-004 (additive, format bump, consumer-safe) | Step 6 + Step 7 | diff --git a/dev/specs/telemetry-collection-infp-589/research.md b/dev/specs/telemetry-collection-infp-589/research.md new file mode 100644 index 00000000000..362d43601ef --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/research.md @@ -0,0 +1,136 @@ +# Phase 0 Research: Phase 1 Telemetry Collection + +All technical context was grounded by reading the existing telemetry module and its +collaborators. No open `NEEDS CLARIFICATION` items remain. + +## Decision 1 — Branch/temporal-correct managed-node count (`corenode`) + +**Decision**: Compute `database.node_count["corenode"]` with +`NodeManager.count(db=..., schema=InfrahubKind.NODE, branch=)`. `InfrahubKind.NODE` +== `"CoreNode"`, the generic every managed node inherits. + +**Rationale**: `NodeManager.count` builds `NodeGetListQuery` which applies branch + temporal +filters (Constitution II). The existing `gather_database_information` uses raw +`utils.count_nodes(db, label=...)` over `GRAPH_SCHEMA["nodes"]` — those are graph-internal +labels (`Node`, `Attribute`, …) and produce raw vertex tallies with no branch/temporal +correctness. `node_count["total"]` is the raw `count_nodes(db)` vertex total and must stay +as-is (FR-009). The key `corenode` does not collide with any `GRAPH_SCHEMA["nodes"]` label, +so it is a clean additive key. + +**Alternatives considered**: `count_nodes(label="CoreNode")` — rejected: no branch/temporal +filter, would diverge from how GraphQL resolvers count and would not match an +independently-computed fixture (fails SC-003). + +## Decision 2 — Account & branch metrics + +**Decision**: +- `accounts.active` = `NodeManager.count(db, schema=InfrahubKind.ACCOUNT, filters={"status__value": AccountStatus.ACTIVE.value}, branch=)`. +- `accounts.groups` = `NodeManager.count(db, schema=InfrahubKind.ACCOUNTGROUP, branch=)`. +- `branches.active` = count of `registry.branch.values()` where `not is_default and not is_global`. + +**Rationale**: `NodeManager.count` is the same path the account GraphQL resolver uses +(grounded in `graphql/queries/account.py`). `CoreAccount.status` is an enum attribute, so the +`status__value` filter selects `AccountStatus.ACTIVE`. The registry already holds all open +branches; `branches.total` today is `len(registry.branch)`. `is_default` marks `main`, +`is_global` marks `-global-` (`GLOBAL_BRANCH_NAME`), so excluding both yields open +non-system branches. Closed/merged/deleted branches are removed from the registry, so +"registry membership" already means "open". + +**Alternatives considered**: Querying branches from the DB — rejected: the registry is the +in-memory source of truth used elsewhere and avoids an extra query (Constitution V, VII). + +## Decision 3 — NEW 24h-windowed Prefect event path (logins, unique_logins) + +**Decision**: Add a new windowed counter that posts to `/events/count-by/event` with an +`occurred` window (`since = now - 24h`, `until = now`) in the filter, alongside the existing +`event.name` filter. For `account.logged_in`: +- `activity_24h.logins` = the windowed count of `infrahub.account.logged_in` events. +- `activity_24h.unique_logins` = distinct accounts in the same window, obtained by counting + by **resource** (`/events/count-by/resource`) over the windowed `logged_in` filter and + taking the number of buckets. Each login event's `prefect.resource.id` is + `infrahub.account.{account_id}` (grounded in `AccountLoggedInEvent.get_resource`), so one + bucket per distinct account ⇒ bucket count = unique logins. + +The existing `gather_prefect_events` (no time window) is **left untouched** (FR-007); the new +path is separate functions feeding `activity_24h`. + +**Rationale**: Logins are events stored in Prefect (ADR 0002); Neo4j has no `last_login`. The +event name is `infrahub.account.logged_in` (`AccountLoggedInEvent.event_name`). The 24h +window (≪ 7-day event retention) guarantees no retention leakage when the `occurred` filter +is applied (SC-002). Counting by resource id is the natural distinct-count primitive without +pulling every event. + +**Alternatives considered**: (a) modify `gather_prefect_events` to add a window — rejected by +FR-007 (must not change existing output). (b) pull all events and de-dup in Python — +rejected: heavier, and Prefect's count-by primitives do it server-side (Constitution V). + +## Decision 4 — Webhook success/failure over 24h + +**Decision**: `activity_24h.webhooks_fired_success` / `_failure` come from Prefect **flow +runs** of the `webhook-process` flow (grounded: `@flow(name="webhook-process")` in +`webhook/tasks/process.py`) started within the trailing 24h, split by terminal state: +`COMPLETED` ⇒ success; `FAILED` / `CRASHED` (and `TIMEDOUT`) ⇒ failure. Query via the Prefect +client's flow-run read API filtered by flow name and `start_time >= now - 24h`. + +**Rationale**: Webhook delivery is a flow run, not an InfrahubEvent, so flow-run state is the +correct signal. Webhook flow-run retention is 90 days (≫ 24h), so the window is always fully +covered. Counts are best-effort trend signals (dispatch can drop), framed against windowing +correctness, not an external ground truth. + +**Alternatives considered**: Deriving from events — rejected: webhook outcome lives in the +flow-run state, not an event. + +## Decision 5 — Graceful degradation contract (`null` vs `0`) + +**Decision**: New payload fields are `int | None`. Introduce a single async helper in +`tasks.py` that runs a metric coroutine, returns its value on success, and on any exception +logs a warning and returns `None`. The orchestrator (`gather_anonymous_telemetry_data`) +gathers each new metric through this helper, so one failing source ⇒ that field `null`, the +rest of the payload still built, stored, and sent. A source that succeeds with nothing to +count returns `0` naturally (e.g. `NodeManager.count` ⇒ 0, empty window ⇒ 0). + +**Rationale**: FR-010 / SC-001 require per-metric isolation and a `null`-means-failure, +`0`-means-empty convention. A single helper serving ≥2 callers is the justified extraction +(Constitution VII). Existing required fields are **not** widened to optional (FR-011) — only +the new fields are nullable. + +**`node_count` value-type note**: `node_count` is currently `dict[str, int]`. To let +`corenode` be `null` on failure while keeping it inside `node_count` (the field name the +contract mandates), the value type widens to `dict[str, int | None]`. This is additive in +practice: existing keys (`total`, graph labels) are always populated `int`; only the new +`corenode` key can be `null`. A forward-compatible consumer is unaffected. Documented as an +accepted, additive type-widening rather than a meaning/name change (FR-011 honored). + +**Alternatives considered**: Wrapping the whole gather in one try/except — rejected: a single +failure would null unrelated metrics, violating per-metric isolation. + +## Decision 6 — `payload_format` bump + +**Decision**: Bump `TELEMETRY_VERSION` in `constants.py` from `"20250318"` to a new date +string (`"20260628"`). `DEFAULT_PAYLOAD_FORMAT` follows it. The value flows into both the +stored snapshot and the remote payload (`payload_format` key) already. + +**Rationale**: FR-007 requires advancing the format identifier when fields are added. The +codebase already uses a `YYYYMMDD` convention. + +## Decision 7 — Test strategy (grounded) + +**Decision**: +- **SC-003 (corenode)** — component test in `test_datatabase.py`: seed a known number of + managed nodes via existing schema fixtures, independently compute the expected count, assert + `node_count["corenode"]` matches exactly (±0). +- **SC-002 (windowing)** — component/unit test in `test_task_manager.py`: emit `logged_in` + and `webhook-process` records inside and outside the 24h window; assert in-window-only + counts and that `unique_logins` collapses repeat logins per account. +- **SC-001 (presence + degradation)** — new `test_tasks.py`: run the gather flow; assert all + in-scope fields present; force one source to fail (via an injected failing adapter/fixture, + not `unittest.mock`) and assert that field is `null`, others populated, payload still built; + assert genuine-empty ⇒ `0`. + +**Rationale**: Aligns with Constitution IV and `testing-python.md` (no mocking; component +tests via TestContainers; adapter/fixture injection; files mirror source). Existing +`prefect_test_fixture` and telemetry component fixtures are reused. + +**Open consideration for tasks phase**: prefer injecting a failing collaborator over +patching to simulate source failure; if a clean injection seam is awkward in the current +orchestrator, the degradation helper itself is unit-testable in isolation (`test_degradation.py`). From 20c0bd20b18097f032605f9f2063901ea857f721 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Sun, 28 Jun 2026 18:17:54 +0200 Subject: [PATCH 03/75] critique(telemetry): INFP-589 dual-lens critique + Must-Address fixes Verdict PROCEED. Applied two Must-Address fixes: (1) firm up no-mock test strategy (degradation helper unit-tested directly, freezegun for 24h windowing); (2) extend GR-001/SC-004 + contract to require consumer tolerance of null values, including corenode-in-node_count. Documented webhook non-terminal handling and corenode count cost. Co-Authored-By: Claude Opus 4.8 --- .../contracts/telemetry-payload.md | 6 ++- .../telemetry-collection-infp-589/critique.md | 37 +++++++++++++++++++ .../data-model.md | 5 ++- .../telemetry-collection-infp-589/research.md | 36 ++++++++++++++++-- .../telemetry-collection-infp-589/spec.md | 17 ++++++--- 5 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 dev/specs/telemetry-collection-infp-589/critique.md diff --git a/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md b/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md index a476150a0e6..7c8b3b2ff12 100644 --- a/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md +++ b/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md @@ -96,6 +96,8 @@ These are the new/changed producer-side function contracts (full signatures land ## Governance (GR-001) -Before shipping: confirm the cloud processor and BigQuery/Metabase data mart tolerate the -`payload_format` bump and ignore unknown fields. Additive design means a forward-compatible +Before shipping: confirm the cloud processor and BigQuery/Metabase data mart (a) tolerate the +`payload_format` bump, (b) ignore unknown fields, and (c) tolerate `null` values on the new +fields — including a `null` on the `corenode` key inside `node_count`, the only place a +previously all-integer map can now carry a `null`. Additive design means a forward-compatible consumer keeps working; this is a release gate, not a code dependency. diff --git a/dev/specs/telemetry-collection-infp-589/critique.md b/dev/specs/telemetry-collection-infp-589/critique.md new file mode 100644 index 00000000000..84c27c5b21a --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/critique.md @@ -0,0 +1,37 @@ +# Critique Report: Phase 1 Telemetry Collection + +**Date**: 2026-06-28 +**Lenses**: Product + Engineering +**Inputs**: `spec.md`, `plan.md` (+ research/data-model/contracts) +**Verdict**: ✅ PROCEED (Must-Address findings applied inline) + +## Product lens + +The spec is well-scoped and producer-only with crisp in/out-of-scope boundaries, prioritized +independently-testable user stories, measurable success criteria, and a governance gate +(GR-001). The blocked `user_node_count` (IFC-2825) and Phase 2 items are explicitly excluded. +No scope creep observed relative to the PRD. No product-level Must-Address items. + +## Engineering lens + +The plan is grounded in the actual telemetry module. Branch-safety (Constitution II) is honored +by routing node/account counts through `NodeManager.count` and explicitly avoiding raw label +counts for `corenode`. The existing unwindowed event tally is preserved (FR-007). Per-metric +graceful degradation is the right shape for FR-010/SC-001. Two Must-Address gaps were found and +fixed; two recommendations were applied. + +## Findings + +| # | Severity | Lens | Finding | Resolution | +|---|----------|------|---------|------------| +| 1 | 🎯 Must-Address | Eng | Test strategy for SC-001/SC-002 was an "open consideration" and didn't name a deterministic-time approach — risk of slipping into `unittest.mock`, which `testing-python.md` forbids. | Firmed up in `research.md` Decision 7: degradation helper unit-tested directly with raising/returning coroutines (no mock); `freezegun` pins time for windowing; Prefect `.fn`/logger handled via the allowed pattern. | +| 2 | 🎯 Must-Address | Eng/Prod | Widening `node_count` to `dict[str, int \| None]` means a previously all-integer map can carry a `null` on `corenode`. GR-001/SC-004 only mentioned "ignores unknown fields", not "tolerates null values". A strict consumer could choke. | GR-001 + SC-004 (spec) and the contract now explicitly require the consumer to tolerate `null` values, calling out the `corenode`-in-`node_count` case. | +| 3 | 💡 Recommendation | Eng | Behavior of in-window-but-non-terminal `webhook-process` runs was unspecified. | Documented in `data-model.md` + `research.md` Decision 8: non-terminal runs counted as neither success nor failure (trend-signal semantics). | +| 4 | 💡 Recommendation | Eng | `NodeManager.count(CoreNode)` cost on large deployments (Constitution V) unaddressed. | `research.md` Decision 8: single aggregate query, daily batch — acceptable; no benchmark required this phase, deliberate over per-label summation. | +| 5 | 🤔 Question | Prod | Does `branches.active` include open-but-merged branches lingering in the registry? | Resolved: registry membership already means "open" (closed/deleted branches are evicted), consistent with the existing `branches.total`. No change. | + +## Constitution re-check (post-critique) + +All seven principles still pass. The `node_count` type-widening is the only contract subtlety +and is now explicitly gated (GR-001) and documented as additive-in-practice (no existing key +ever `null`). No new entities, no new dependencies, no schema changes. diff --git a/dev/specs/telemetry-collection-infp-589/data-model.md b/dev/specs/telemetry-collection-infp-589/data-model.md index 575ee0419e8..612a94f1d82 100644 --- a/dev/specs/telemetry-collection-infp-589/data-model.md +++ b/dev/specs/telemetry-collection-infp-589/data-model.md @@ -24,7 +24,10 @@ Counts via `NodeManager.count` on the default branch (branch/temporal-correct). | `webhooks_fired_success` | `int \| None` | `webhook-process` flow runs in 24h ending `COMPLETED` | `0` | `null` | | `webhooks_fired_failure` | `int \| None` | `webhook-process` flow runs in 24h ending `FAILED`/`CRASHED`/`TIMEDOUT` | `0` | `null` | -Each field is isolated: one failing source nulls only its own field. +Each field is isolated: one failing source nulls only its own field. A `webhook-process` run +that started in-window but is still non-terminal (`PENDING`/`RUNNING`/`SCHEDULED`) at gather +time is counted in neither success nor failure — only terminal outcomes are tallied (a +best-effort daily trend signal). ## Extended models diff --git a/dev/specs/telemetry-collection-infp-589/research.md b/dev/specs/telemetry-collection-infp-589/research.md index 362d43601ef..d1e05f68351 100644 --- a/dev/specs/telemetry-collection-infp-589/research.md +++ b/dev/specs/telemetry-collection-infp-589/research.md @@ -131,6 +131,36 @@ codebase already uses a `YYYYMMDD` convention. tests via TestContainers; adapter/fixture injection; files mirror source). Existing `prefect_test_fixture` and telemetry component fixtures are reused. -**Open consideration for tasks phase**: prefer injecting a failing collaborator over -patching to simulate source failure; if a clean injection seam is awkward in the current -orchestrator, the degradation helper itself is unit-testable in isolation (`test_degradation.py`). +**No-mock seam (decided, not open)**: The `null`-vs-`0` contract is proven WITHOUT any +`unittest.mock` by two complementary, decided approaches: + +1. **Degradation helper unit test** (`test_degradation.py`): the helper takes a coroutine and + returns its value or `None` on exception. Pass it (a) a coroutine that `raise`s → assert + `None`; (b) a coroutine returning `0` → assert `0`; (c) a coroutine returning `N` → assert + `N`. No DB, no mock — a plain failing/succeeding coroutine is the test double. +2. **Flow presence test** (`test_tasks.py`): assert every in-scope field is present in the + gathered payload on a healthy stack. End-to-end "one source nulled, rest populated" is + covered by composing the helper (proven in #1) with the orchestrator wiring; if a natural + failing-source fixture is cheap (e.g. pointing a gather at an absent Prefect resource) it is + added, but the contract does NOT depend on mocking a failure end-to-end. + +**Deterministic time (decided)**: the 24h-window tests (SC-002) use `freezegun` to pin "now" +(an explicitly allowed exception in `testing-python.md` for time-dependent behavior), so +in-window vs out-of-window fixtures are unambiguous and non-flaky. + +**Prefect `.fn` + logger**: where a `@task`/`@flow`-decorated function is exercised via `.fn` +outside a flow context, `get_run_logger` is handled per the allowed `testing-python.md` +pattern (return a stdlib logger), not via general mocking. + +## Decision 8 — Webhook run terminality & count cost (secondary) + +**Webhook non-terminal runs**: a `webhook-process` run that started in-window but is still +`PENDING`/`RUNNING`/`SCHEDULED` at gather time is counted as neither success nor failure. This +is correct for a best-effort daily trend signal — only terminal outcomes are tallied. Captured +in the contract and data model. + +**`corenode` count cost (Constitution V)**: `NodeManager.count(CoreNode)` is a single aggregate +query (order disabled) over the managed-node generic with branch/temporal filters — no N+1, no +node materialization. On very large deployments this is still a full count; it runs once per +day in a batch job, so the cost is acceptable. A benchmark is not required for this phase but +the single-aggregate shape is a deliberate choice over per-label summation. diff --git a/dev/specs/telemetry-collection-infp-589/spec.md b/dev/specs/telemetry-collection-infp-589/spec.md index fda0c6ce46f..ccd45aeb56e 100644 --- a/dev/specs/telemetry-collection-infp-589/spec.md +++ b/dev/specs/telemetry-collection-infp-589/spec.md @@ -207,9 +207,12 @@ field is populated, and the payload is still sent/stored. - **GR-001**: Before shipping, the payload contract change (the `payload_format` bump and the new fields) MUST be confirmed compatible with the receiving end (the cloud telemetry processor and the downstream data mart) — specifically - that the receiver tolerates the format bump and ignores unknown fields. Because - every change is additive, a forward-compatible consumer keeps working; this is - a confirmation gate, not a code dependency. + that the receiver (a) tolerates the format bump, (b) ignores unknown fields, + and (c) tolerates `null` values on the new fields, including a `null` value on + the new `corenode` key inside the existing `node_count` object (the only place + a previously all-integer map can now carry a `null`). Because every change is + additive, a forward-compatible consumer keeps working; this is a confirmation + gate, not a code dependency. ### Key Entities *(include if feature involves data)* @@ -243,9 +246,11 @@ field is populated, and the payload is still sent/stored. - **SC-003**: `database.node_count.corenode` matches an independently-computed fixture count exactly (±0). - **SC-004**: No existing telemetry field changes meaning, type, or name across - this release; the `payload_format` identifier is advanced and a - forward-compatible consumer that ignores unknown fields continues to parse the - payload (confirmed with the receiving team per GR-001). + this release (the `node_count` map may carry a `null` value only on the new + `corenode` key); the `payload_format` identifier is advanced and a + forward-compatible consumer that ignores unknown fields and tolerates `null` + values continues to parse the payload (confirmed with the receiving team per + GR-001). ## Assumptions From 0c6c87c086d0ae1c2b74ead22287f50efaae1f17 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Sun, 28 Jun 2026 19:06:58 +0200 Subject: [PATCH 04/75] tasks(telemetry): INFP-589 dependency-ordered task breakdown 28 tasks across Setup, Foundational (models, payload_format bump, degradation helper), US1 activity_24h enabler (MVP), US2 accounts/branches, US3 corenode, US4 full-payload resilience, and Polish (incl. GR-001 governance gate). TDD test-first per Constitution IV; no-mock + freezegun guardrails noted. Co-Authored-By: Claude Opus 4.8 --- .../telemetry-collection-infp-589/tasks.md | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 dev/specs/telemetry-collection-infp-589/tasks.md diff --git a/dev/specs/telemetry-collection-infp-589/tasks.md b/dev/specs/telemetry-collection-infp-589/tasks.md new file mode 100644 index 00000000000..5a54bb262da --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/tasks.md @@ -0,0 +1,236 @@ +--- +description: "Task list for Phase 1 Telemetry Collection" +--- + +# Tasks: Phase 1 Telemetry Collection + +**Input**: Design documents from `specs/telemetry-collection-infp-589/` + +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/telemetry-payload.md + +**Tests**: Included — Constitution IV requires component tests for SC-001/SC-002/SC-003 and a +unit test for the degradation helper. TDD: write each test first and confirm it fails before +implementing. + +**Organization**: Tasks are grouped by user story. The degradation helper, the additive model +changes, and the `payload_format` bump are genuinely shared, so they live in Foundational +(Phase 2). US4 (resilient payload, P1) is realized by that shared mechanism plus a +full-payload resilience test that runs last, since it asserts every in-scope field is present. + +## Conventions & Guardrails + +- **No work-item / requirement IDs in source.** Do NOT write `FR-xxx`, `SC-xxx`, `IFC-xxxx`, + `INFP-589`, or task IDs in code, docstrings, comments, or test names + (`.agents/rules/code-doc-style.md`). Those IDs stay in this file and in commit messages. +- **No mocking.** No `unittest.mock` / `MagicMock` / `patch`. Use plain coroutines as test + doubles for the degradation helper; use real fixtures/`prefect_test_fixture` for component + tests. `freezegun` is the allowed tool for pinning time; `get_run_logger` may be handled per + the allowed `testing-python.md` pattern when calling `@task`/`@flow` via `.fn`. +- **Branch-safe counts.** Node/account counts go through `NodeManager.count` on the default + branch — never raw `count_nodes(label=...)` for `corenode`. +- **Additive only.** Never change an existing field's name/type/meaning (the sole exception is + widening the `node_count` value type so the new `corenode` key may be `null`). +- **Keyword arguments** for all calls; full type hints; `str | None` style. +- Commit after each task or logical group. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Pre-flight; the telemetry module already exists, so setup is minimal. + +- [ ] T001 [P] Add a Towncrier changelog fragment under `changelog/` (e.g. `+telemetry-phase1.added.md`) describing the new additive telemetry fields and the `payload_format` bump. +- [ ] T002 [P] Confirm the telemetry test layout exists and create empty skeletons where missing: `backend/tests/unit/telemetry/test_degradation.py`, `backend/tests/component/telemetry/test_tasks.py` (mirror source structure; no assertions yet). + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Shared payload contract + degradation mechanism that every metric depends on. + +**⚠️ CRITICAL**: No user story metric can be wired until this phase is complete. + +- [ ] T003 Bump `TELEMETRY_VERSION` in `backend/infrahub/telemetry/constants.py` from `"20250318"` to `"20260628"` (this also advances `DEFAULT_PAYLOAD_FORMAT`). +- [ ] T004 In `backend/infrahub/telemetry/models.py`, add the additive payload models: + - `TelemetryAccountData` with `active: int | None` and `groups: int | None`. + - `TelemetryActivity24hData` with `logins`, `unique_logins`, `webhooks_fired_success`, `webhooks_fired_failure`, all `int | None`. + - Extend `TelemetryBranchData` with `active: int | None = None` (keep `total: int`). + - Widen `TelemetryDatabaseData.node_count` value type to `dict[str, int | None]`. + - Add `accounts: TelemetryAccountData` and `activity_24h: TelemetryActivity24hData` to `TelemetryData` (always-present objects; per-field nullability). +- [ ] T005 [P] Write the degradation-helper unit test in `backend/tests/unit/telemetry/test_degradation.py` (TDD — must fail first): a coroutine that raises → helper returns `None`; a coroutine returning `0` → `0`; a coroutine returning `N` → `N`. No DB, no mock (plain coroutines as doubles). +- [ ] T006 Implement the async graceful-degradation helper in `backend/infrahub/telemetry/tasks.py`: runs a metric coroutine, returns its result, and on any exception logs a warning and returns `None`. Make T005 pass. + +**Checkpoint**: Payload models, version bump, and degradation helper ready. Stories can begin. + +--- + +## Phase 3: User Story 1 — Activity 24h enabler (Priority: P1) 🎯 MVP + +**Goal**: Emit `activity_24h` (logins, unique_logins, webhooks success/failure) over the +trailing 24h via a NEW windowed Prefect path, without touching the existing unwindowed event +tally; wire it into the daily payload with per-field degradation. + +**Independent Test**: Seed login + webhook-process records inside and outside the trailing 24h; +the gathered `activity_24h` reflects exactly the in-window records, `unique_logins` collapses +repeat logins per account, and the existing `prefect.events.*` output is unchanged. + +### Tests for User Story 1 (write first, must fail) ⚠️ + +- [ ] T007 [P] [US1] Component test for windowed logins + unique_logins in `backend/tests/component/telemetry/test_task_manager.py`: with `freezegun` pinning "now", seed `account.logged_in` events in- and out-of-window (and repeat logins from one account); assert in-window-only `logins` and distinct-account `unique_logins`. +- [ ] T008 [P] [US1] Component test for webhook success/failure split over 24h in `backend/tests/component/telemetry/test_task_manager.py`: seed terminal `webhook-process` flow runs (completed + failed) in- and out-of-window; assert correct counts and that non-terminal runs are excluded. +- [ ] T009 [P] [US1] Regression test asserting `gather_prefect_events` output is unchanged (existing unwindowed tally still present and untouched). + +### Implementation for User Story 1 + +- [ ] T010 [US1] In `backend/infrahub/telemetry/task_manager.py`, add a NEW windowed event counter that posts to `/events/count-by/event` with an `occurred` window (`since = now - 24h`, `until = now`) plus the `event.name` filter — separate from `gather_prefect_events`, which stays untouched. +- [ ] T011 [US1] In `task_manager.py`, add a windowed unique-account counter posting to `/events/count-by/resource` over the same `account.logged_in` window; the number of resource buckets (keyed by `infrahub.account.{account_id}`) is `unique_logins`. +- [ ] T012 [US1] In `task_manager.py`, add a `webhook-process` flow-run query over the trailing 24h, splitting terminal states into success (`COMPLETED`) and failure (`FAILED`/`CRASHED`/`TIMEDOUT`); non-terminal runs counted in neither. +- [ ] T013 [US1] In `task_manager.py`, add `gather_activity_24h(client) -> TelemetryActivity24hData` assembling the four counts, each obtained through the degradation helper so one failing source nulls only its own field. +- [ ] T014 [US1] In `backend/infrahub/telemetry/tasks.py`, wire `activity_24h` into `gather_anonymous_telemetry_data` (gather via the Prefect client path; the object is always present). + +**Checkpoint**: `activity_24h` present and windowed; existing event output intact. MVP testable. + +--- + +## Phase 4: User Story 2 — Accounts & branches adoption (Priority: P2) + +**Goal**: Emit `accounts.active`, `accounts.groups`, and `branches.active`. + +**Independent Test**: Seed known active/inactive accounts, account groups, and +open/system branches; assert each reported count matches the fixture exactly. + +### Tests for User Story 2 (write first, must fail) ⚠️ + +- [ ] T015 [P] [US2] Component test for `accounts.active` / `accounts.groups` in `backend/tests/component/telemetry/test_tasks.py`: seed a known mix of active/inactive `CoreAccount` and a known number of `CoreAccountGroup`; assert exact counts via the gather. +- [ ] T016 [P] [US2] Test for `branches.active` (registry-based) in `backend/tests/component/telemetry/test_tasks.py`: with open + system branches present, assert the count excludes the default (`main`) and global (`-global-`) branches. + +### Implementation for User Story 2 + +- [ ] T017 [US2] Add `gather_account_information(db) -> TelemetryAccountData` (in `backend/infrahub/telemetry/tasks.py`, or a small `backend/infrahub/telemetry/accounts.py` if cohesion warrants): `active` via `NodeManager.count(CoreAccount, filters={"status__value": "active"})`, `groups` via `NodeManager.count(CoreAccountGroup)`, both on the default branch, each through the degradation helper. +- [ ] T018 [US2] In `gather_anonymous_telemetry_data` (`tasks.py`), wire `accounts` (from T017) and compute `branches.active` from `registry.branch.values()` excluding `is_default` and `is_global`, via the degradation helper; keep `branches.total` unchanged. + +**Checkpoint**: Account + branch adoption metrics present and exact; `branches.total` untouched. + +--- + +## Phase 5: User Story 3 — Branch-correct managed-node count (Priority: P2) + +**Goal**: Emit `database.node_count.corenode` via the branch/temporal-correct count path, +leaving `node_count.total` (raw vertices) unchanged. + +**Independent Test**: Seed a known number of managed nodes, independently compute the expected +count, and assert `node_count["corenode"]` matches exactly (±0). + +### Tests for User Story 3 (write first, must fail) ⚠️ + +- [ ] T019 [P] [US3] Component test in `backend/tests/component/telemetry/test_datatabase.py`: seed N managed nodes via existing schema fixtures (`backend/tests/helpers/schema/`), independently compute N, assert `node_count["corenode"] == N` exactly and that `node_count["total"]` (raw) is unchanged and `>= N`. + +### Implementation for User Story 3 + +- [ ] T020 [US3] In `backend/infrahub/telemetry/database.py`, set `node_count["corenode"]` via `NodeManager.count(db, schema=InfrahubKind.NODE, branch=)`, wrapped so a failure sets `corenode=None` without affecting `node_count["total"]` or the existing graph-label keys (do NOT use raw `count_nodes(label=...)`). +- [ ] T021 [US3] Add/extend a docstring or module note distinguishing the three node metrics — raw vertex `total`, managed-node `corenode`, and the future (out-of-scope) `user` — without naming tickets/IDs in source. + +**Checkpoint**: `corenode` exact and branch-correct; raw `total` preserved. + +--- + +## Phase 6: User Story 4 — Resilient payload (Priority: P1) + +**Goal**: Guarantee the cross-cutting resilience contract end-to-end: every in-scope field is +present; a failing source yields `null` (not a dropped payload); a genuine empty yields `0`. + +> The mechanism (degradation helper) is delivered in Foundational and consumed by each story +> above. This phase validates the whole payload, so it runs after US1–US3 are wired. + +**Independent Test**: Run the gather flow; assert all in-scope fields present. Force one source +to fail (inject a failing collaborator/fixture — no mock); assert that field is `null`, all +others populated, and the payload is still built and stored. Assert genuine-empty → `0`. + +### Tests for User Story 4 (write first, must fail) ⚠️ + +- [ ] T022 [US4] Component test in `backend/tests/component/telemetry/test_tasks.py`: run `gather_anonymous_telemetry_data` on a healthy stack and assert presence of `accounts.{active,groups}`, `branches.active`, `database.node_count.corenode`, and `activity_24h.{logins,unique_logins,webhooks_fired_success,webhooks_fired_failure}`. +- [ ] T023 [US4] Resilience test in `backend/tests/component/telemetry/test_tasks.py`: make one source fail via an injected failing collaborator/fixture (no mock); assert that field is `null`, every other field is populated, and the snapshot is still stored. Add a genuine-empty case asserting `0`, not `null`. + +### Implementation for User Story 4 + +- [ ] T024 [US4] Audit `gather_anonymous_telemetry_data` in `backend/infrahub/telemetry/tasks.py` to ensure every new metric (accounts, branches.active, corenode, each activity_24h field) is gathered through the degradation helper — no new metric can raise out of the orchestrator. If the current wiring doesn't expose a clean no-mock failure seam for T023, introduce one (e.g. an injectable gather collaborator) following backend component-design DI rules. + +**Checkpoint**: Whole payload resilient; one failing source never drops the rest. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +- [ ] T025 [P] Run the telemetry suites: `uv run pytest backend/tests/unit/telemetry backend/tests/component/telemetry -q` (set `DOCKER_HOST` for component tests). +- [ ] T026 [P] `uv run invoke format lint` and resolve any findings in the telemetry module. +- [ ] T027 Run the `quickstart.md` validation steps end-to-end and confirm `payload_format == "20260628"` in a stored snapshot. +- [ ] T028 **Governance gate (GR-001)** — before merge/release, confirm with the cloud-processor owner and the data-mart owner that the receiver tolerates the `payload_format` bump, ignores unknown fields, and tolerates `null` values (including `corenode` inside `node_count`). Record the confirmation on the PR / tracking ticket. (Process task, not code.) + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: no dependencies. +- **Foundational (Phase 2)**: depends on Setup; **blocks all stories** (models, helper, version). +- **US1 (Phase 3)**, **US2 (Phase 4)**, **US3 (Phase 5)**: each depends only on Foundational; mutually independent (different gather functions / files), so parallelizable across developers. +- **US4 (Phase 6)**: depends on US1–US3 being wired (it asserts the full payload). +- **Polish (Phase 7)**: depends on all desired stories complete. + +### Within Each User Story + +- Tests are written first and must fail before implementation. +- US1: windowed counters (T010–T012) → assembler (T013) → orchestrator wiring (T014). +- US2: gather function (T017) → orchestrator wiring (T018). +- US3: db count (T020) → docs note (T021). + +### Parallel Opportunities + +- T001 / T002 (Setup) in parallel. +- T005 (helper test) parallel with T003/T004 (constant + models). +- US1 test tasks T007/T008/T009 in parallel; US2 T015/T016 in parallel. +- With capacity: US1, US2, US3 proceed in parallel once Foundational is done. +- Polish T025/T026 in parallel. + +--- + +## Parallel Example: User Story 1 + +```bash +# Write US1 tests together (they must fail first): +Task: "Component test windowed logins/unique_logins in backend/tests/component/telemetry/test_task_manager.py" +Task: "Component test webhook success/failure split in backend/tests/component/telemetry/test_task_manager.py" +Task: "Regression test gather_prefect_events unchanged" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1) + +1. Phase 1 Setup → Phase 2 Foundational (CRITICAL). +2. Phase 3 US1 (activity_24h enabler). +3. **STOP and VALIDATE**: windowing + existing-output-untouched, independently. +4. Demo the new `activity_24h` object. + +### Incremental Delivery + +1. Setup + Foundational → contract + mechanism ready. +2. US1 → trailing-24h activity (MVP). +3. US2 → account/branch adoption. +4. US3 → branch-correct scaling count. +5. US4 → full-payload resilience guarantee. +6. Polish → suite green, lint clean, GR-001 governance confirmed. + +--- + +## Notes + +- `[P]` = different files, no incomplete-task dependency. +- `[Story]` label maps each task to a user story for traceability (this file only — never in source). +- Verify each test fails before implementing. +- US4's value is P1, but its full-payload assertion depends on US1–US3, so it is scheduled last. +- Out of scope (do not implement): `database.node_count.user` (blocked), Phase 2 metrics, + dashboards, redefining `node_count.total`, persisting logins in Neo4j. From 2a01b7b01d209c58a69112d40436390e07b96a20 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Sun, 28 Jun 2026 19:07:35 +0200 Subject: [PATCH 05/75] =?UTF-8?q?align(telemetry):=20INFP-589=20spec/ask?= =?UTF-8?q?=20alignment=20check=20=E2=80=94=20ALIGNED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source: inline handoff PRD (no URLs). Verdict ALIGNED, 0 remediation passes. All in-scope FRs and SCs faithfully carried; SC-004 + null-tolerance are justified clarifications, not scope creep. Out-of-scope items preserved. Co-Authored-By: Claude Opus 4.8 --- .../alignment-check.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 dev/specs/telemetry-collection-infp-589/alignment-check.md diff --git a/dev/specs/telemetry-collection-infp-589/alignment-check.md b/dev/specs/telemetry-collection-infp-589/alignment-check.md new file mode 100644 index 00000000000..f01435d2387 --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/alignment-check.md @@ -0,0 +1,33 @@ +# Spec/Ask Alignment Check: Phase 1 Telemetry Collection + +**Date**: 2026-06-28 +**Remediation passes used**: 0 + +## 1. Source + +Inline PRD: `INFP-589-phase1-handoff.md` (the feature-description block + Functional +Requirements + Success Criteria + Code pointers + Governance gate + Parked decision). No URLs +present in the ask — Jira/JPD items are referenced by ID only, so no web fetch was needed. + +## 2. Verdict + +✅ **ALIGNED** — no significant drift. The spec is a faithful, expanded restatement of the +PRD. Additions are necessary clarifications, not scope creep. + +## 3. Findings + +| Severity | Category | PRD reference | Spec reference | Description | +|----------|----------|---------------|----------------|-------------| +| ✅ none | — | FR-001..003, 005..011 | FR-001..003, 005..011 | All in-scope FRs present verbatim in intent; FR-004 correctly omitted (blocked). Numbering matches the PRD exactly. | +| ✅ none | — | "Success" SC-001/002/003 | SC-001/002/003 | All three success criteria carried over with the same meaning (presence+null-on-failure, exact 24h window, corenode ±0). | +| ✅ none | — | "Governance gate" | GR-001 | Receiving-end confirmation captured as a release gate. | +| ✅ none | — | "Out of scope" | Out of Scope section | user_node_count, Phase 2 items, dashboards, redefining node_count.total, persisting logins — all preserved. | +| ℹ️ info (added, justified) | added | FR-011 + Governance gate | SC-004 | Spec adds SC-004 (additive-only + format-bump consumer compatibility). This is a measurable restatement of FR-011 + the governance gate, not new scope. | +| ℹ️ info (added, justified) | added | — (engineering clarification) | GR-001(c), SC-004, contract | Critique added explicit "consumer tolerates `null` values (incl. corenode in node_count)" tolerance. This clarifies the additive contract implied by FR-010/FR-011 + the `node_count.corenode` requirement; it does not change scope. | +| ℹ️ info (expansion) | — | "Facts" / best-effort note | Edge Cases + Assumptions | PRD's best-effort/retention facts expanded into edge cases and assumptions. Expansion of detail, allowed. | + +No `missing`, `changed`, `dropped`, or `contradicted` findings. + +## 4. Action + +Proceed. `tasks.md` is generated and aligned with the source PRD. No phases re-run. From 0d963a00ed5cef13a1884ec43f0a8f61bdaead67 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Sun, 28 Jun 2026 21:29:13 +0200 Subject: [PATCH 06/75] refine(telemetry): INFP-589 deterministic 24h window + namespace-pinned node metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer feedback on prep output: - Anchor activity_24h to the previous full UTC calendar day (not gather-time now), so daily snapshots tile with no overlap/gap despite the jittered cron minute and execution drift (sharpens SC-002). - Pin node-metric definitions at the namespace level: corenode = Core+Builtin+ user namespaces (always includes Core mgmt), user = customer-facing subset excluding Core; user ⊆ corenode ⊆ total, so they can never become synonyms (sharpens FR-009). Verified via get_labels(). Co-Authored-By: Claude Opus 4.8 --- .../alignment-check.md | 19 +++++++ .../contracts/telemetry-payload.md | 26 ++++++++-- .../data-model.md | 26 ++++++++-- .../telemetry-collection-infp-589/plan.md | 5 +- .../telemetry-collection-infp-589/research.md | 52 +++++++++++++++++-- .../telemetry-collection-infp-589/spec.md | 42 ++++++++++----- .../telemetry-collection-infp-589/tasks.md | 9 ++-- 7 files changed, 147 insertions(+), 32 deletions(-) diff --git a/dev/specs/telemetry-collection-infp-589/alignment-check.md b/dev/specs/telemetry-collection-infp-589/alignment-check.md index f01435d2387..2f5d76afb9b 100644 --- a/dev/specs/telemetry-collection-infp-589/alignment-check.md +++ b/dev/specs/telemetry-collection-infp-589/alignment-check.md @@ -31,3 +31,22 @@ No `missing`, `changed`, `dropped`, or `contradicted` findings. ## 4. Action Proceed. `tasks.md` is generated and aligned with the source PRD. No phases re-run. + +## 5. Post-review refinements (2026-06-28, reviewer feedback) + +Two design refinements applied after the user reviewed the prep output. Both keep the spec +aligned with the PRD — they make existing requirements precise, they do not add/drop scope: + +1. **24h window anchoring (sharpens SC-002).** The daily flow runs at a per-deployment-random + minute (`cron=f"{random.randint(0, 59)} 2 * * *"`, `workflows/catalogue.py`). Anchoring the + window to gather-time `now` would make consecutive daily windows overlap or gap under + execution drift — violating SC-002's "no overlap/leakage". The window is now anchored to a + deterministic boundary: the **previous full UTC calendar day** `[midnight-24h, midnight)`. + Updated in spec (SC-002, edge cases, assumptions, `activity_24h` entity), plan (constraints), + research (Decision 3 + 4), data-model, contract, and tasks (new T009b helper + T007/T010/T012). +2. **Node-metric definitions pinned at the namespace level (sharpens FR-009).** Verified via + `get_labels()` that `corenode` = all `CoreNode`-labelled nodes (`Core` + `Builtin` + + user-defined namespaces), which always includes the non-empty `Core` management namespace; + the future `user` metric excludes `Core`, so `user ⊆ corenode ⊆ total` strictly and they can + never become synonyms (relevant because FR-011 forbids removing a shipped field). Updated in + spec (FR-009), research (Decision 1), data-model, contract, and tasks (T021). diff --git a/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md b/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md index 7c8b3b2ff12..6a95d739873 100644 --- a/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md +++ b/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md @@ -43,7 +43,7 @@ data mart) is forward-compatible: it ignores unknown fields, so additive changes } }, - "activity_24h": { // NEW object — trailing 24h window + "activity_24h": { // NEW object — previous full UTC calendar day [00:00, 00:00) "logins": 19, // int | null "unique_logins": 6, // int | null "webhooks_fired_success": 41, // int | null @@ -54,16 +54,32 @@ data mart) is forward-compatible: it ignores unknown fields, so additive changes ## Field semantics +The 24h window is the **previous full UTC calendar day** `[window_start, window_end)` with +`window_end = floor_to_midnight_utc(now)`, `window_start = window_end - 24h` — anchored to a +deterministic boundary (not gather-time `now`) so daily snapshots tile exactly. + | Path | Source | Window | Empty | Failure | |------|--------|--------|-------|---------| | `branches.active` | registry: `branch.values()` minus `is_default`/`is_global` | current | `0` | `null` | | `accounts.active` | `NodeManager.count(CoreAccount, status=ACTIVE)` | current | `0` | `null` | | `accounts.groups` | `NodeManager.count(CoreAccountGroup)` | current | `0` | `null` | | `database.node_count.corenode` | `NodeManager.count(CoreNode)` | current | `0` | `null` | -| `activity_24h.logins` | Prefect `account.logged_in` events, windowed | trailing 24h | `0` | `null` | -| `activity_24h.unique_logins` | Prefect count-by-resource on login events, windowed | trailing 24h | `0` | `null` | -| `activity_24h.webhooks_fired_success` | Prefect `webhook-process` flow runs, `COMPLETED` | trailing 24h | `0` | `null` | -| `activity_24h.webhooks_fired_failure` | Prefect `webhook-process` flow runs, `FAILED`/`CRASHED`/`TIMEDOUT` | trailing 24h | `0` | `null` | +| `activity_24h.logins` | Prefect `account.logged_in` events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.unique_logins` | Prefect count-by-resource on login events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.webhooks_fired_success` | Prefect `webhook-process` flow runs, `COMPLETED` | prev. UTC day | `0` | `null` | +| `activity_24h.webhooks_fired_failure` | Prefect `webhook-process` flow runs, `FAILED`/`CRASHED`/`TIMEDOUT` | prev. UTC day | `0` | `null` | + +**Node-count metrics (FR-009).** `node_count` carries three semantically distinct, strictly +nesting keys — `user ⊆ corenode ⊆ total`: + +| Key | Counts | Namespace scope | +|-----|--------|-----------------| +| `total` | raw vertices | n/a (raw graph) | +| `corenode` | all managed nodes (this phase) | `Core` + `Builtin` + user-defined | +| `user` (future) | customer-facing subset | excludes `Core`; `Builtin` in/out is parked (IFC-2825) | + +`corenode` always includes the `Core` management namespace (always non-empty); `user` never +does — so the two can never coincide. ## Invariants the consumer can rely on diff --git a/dev/specs/telemetry-collection-infp-589/data-model.md b/dev/specs/telemetry-collection-infp-589/data-model.md index 612a94f1d82..b8e1904bcbb 100644 --- a/dev/specs/telemetry-collection-infp-589/data-model.md +++ b/dev/specs/telemetry-collection-infp-589/data-model.md @@ -17,12 +17,17 @@ Counts via `NodeManager.count` on the default branch (branch/temporal-correct). ### `TelemetryActivity24hData` +The window is the **previous full UTC calendar day** `[window_start, window_end)` where +`window_end = floor_to_midnight_utc(now)` and `window_start = window_end - 24h` — anchored to a +deterministic calendar boundary, NOT to gather-time `now`, so consecutive daily runs tile +exactly (no overlap, no gap) despite the jittered cron minute and execution drift. + | Field | Type | Meaning | Empty | Failure | |---------------------------|---------------|---------------------------------------------------------|-------|---------| -| `logins` | `int \| None` | `account.logged_in` events in the trailing 24h | `0` | `null` | +| `logins` | `int \| None` | `account.logged_in` events in the windowed day | `0` | `null` | | `unique_logins` | `int \| None` | Distinct `account_id` among those logins (same window) | `0` | `null` | -| `webhooks_fired_success` | `int \| None` | `webhook-process` flow runs in 24h ending `COMPLETED` | `0` | `null` | -| `webhooks_fired_failure` | `int \| None` | `webhook-process` flow runs in 24h ending `FAILED`/`CRASHED`/`TIMEDOUT` | `0` | `null` | +| `webhooks_fired_success` | `int \| None` | `webhook-process` flow runs in-window ending `COMPLETED`| `0` | `null` | +| `webhooks_fired_failure` | `int \| None` | `webhook-process` flow runs in-window ending `FAILED`/`CRASHED`/`TIMEDOUT` | `0` | `null` | Each field is isolated: one failing source nulls only its own field. A `webhook-process` run that started in-window but is still non-terminal (`PENDING`/`RUNNING`/`SCHEDULED`) at gather @@ -49,6 +54,21 @@ best-effort daily trend signal). Widening is additive in practice: existing keys are always populated `int`; only `corenode` may be `null`. No existing key changes meaning or name (FR-011). +**Three node metrics, defined at the namespace level (FR-009).** `CoreNode` is applied to every +node outside the `Schema`/`Internal` namespaces (and non-groups), so the three nest strictly — +`user ⊆ corenode ⊆ total`: + +| Key | Counts | Namespace scope | +|-----|--------|-----------------| +| `total` | raw vertices (incl. attributes/values/internal bookkeeping) | n/a (raw graph) | +| `corenode` | all managed nodes (this phase) | `Core` + `Builtin` + user-defined | +| `user` (future, IFC-2825) | customer-facing subset | excludes `Core` management namespace; `Builtin` in/out is the parked decision | + +Because the `Core` management namespace (accounts, repositories, proposed changes, webhooks, +profiles, …) is always non-empty and is always in `corenode` but never in `user`, the two can +never collapse into the same value. Pinning these definitions here prevents a later `user` +definition from becoming a synonym for `corenode` (which FR-011 would then make unremovable). + ### `TelemetryData` (root, extended) | Field | Type | Status | diff --git a/dev/specs/telemetry-collection-infp-589/plan.md b/dev/specs/telemetry-collection-infp-589/plan.md index c9fa3f70228..8f67ad009f0 100644 --- a/dev/specs/telemetry-collection-infp-589/plan.md +++ b/dev/specs/telemetry-collection-infp-589/plan.md @@ -41,8 +41,9 @@ gather flow; unit tests for windowing/degradation logic where no DB is required (`NodeManager.count` / Prefect count-by). No per-node iteration, no N+1. **Constraints**: Additive only — no existing field changes meaning/type/name. Per-metric -isolation: one failing source must not drop the payload. Event metrics must reflect exactly -the trailing 24h with no retention leakage. +isolation: one failing source must not drop the payload. Event metrics must reflect exactly a +24h window anchored to a deterministic calendar boundary (previous full UTC day), not to +job-execution time, so daily snapshots tile with no overlap/gap despite the jittered cron. **Scale/Scope**: ~9 new payload fields across 2 new sub-models + 2 extended sub-models; ~3 new gather functions; 1 degradation helper; 1 constant bump. Producer-only. diff --git a/dev/specs/telemetry-collection-infp-589/research.md b/dev/specs/telemetry-collection-infp-589/research.md index d1e05f68351..aef008cd6af 100644 --- a/dev/specs/telemetry-collection-infp-589/research.md +++ b/dev/specs/telemetry-collection-infp-589/research.md @@ -17,6 +17,27 @@ correctness. `node_count["total"]` is the raw `count_nodes(db)` vertex total and as-is (FR-009). The key `corenode` does not collide with any `GRAPH_SCHEMA["nodes"]` label, so it is a clean additive key. +**Namespace semantics (locked, so `corenode` and the future `user` can never become +synonyms)**: `get_labels()` (`core/node/__init__.py`) applies the `CoreNode` label to every +node whose namespace is **not** `Schema`/`Internal` and which is not a group. So +`NodeManager.count(CoreNode)` counts the **`Core` + `Builtin` + user-defined namespaces** — +including Infrahub's own management objects (`CoreAccount`, `CoreRepository`, +`CoreProposedChange`, `CoreWebhook`, profiles, resource pools, artifacts, …). The three node +metrics therefore nest strictly: + +``` +user ⊆ corenode ⊆ total +``` + +- `total` — raw vertices (incl. attributes/values/internal bookkeeping). +- `corenode` — **all** managed nodes across `Core` + `Builtin` + user namespaces (this phase). +- `user` (future, IFC-2825) — customer-facing subset that **excludes the `Core` management + namespace**; the parked decision only chooses whether `Builtin` is in or out, i.e. it slides + the `user` ⊂ `corenode` gap but never closes it (the `Core` namespace is always non-empty). + +This pins the definitions at the namespace level so a later `user` definition cannot +accidentally equal `corenode` — a real concern given FR-011 forbids removing a shipped field. + **Alternatives considered**: `count_nodes(label="CoreNode")` — rejected: no branch/temporal filter, would diverge from how GraphQL resolvers count and would not match an independently-computed fixture (fails SC-003). @@ -41,9 +62,31 @@ in-memory source of truth used elsewhere and avoids an extra query (Constitution ## Decision 3 — NEW 24h-windowed Prefect event path (logins, unique_logins) +**Window anchor (decided)**: The 24h window is anchored to a **deterministic calendar +boundary, NOT to `datetime.now()` at gather time**. Compute: + +``` +window_end = floor_to_midnight_utc(now) # 00:00:00 UTC of the current day +window_start = window_end - 24h # 00:00:00 UTC of the previous day +``` + +so each daily run reports the **previous full UTC calendar day** `[window_start, window_end)`. + +Rationale: the daily flow is scheduled `f"{random.randint(0, 59)} 2 * * *"` +(`workflows/catalogue.py`) — a per-deployment-fixed minute, firing at 02:XX. Anchoring the +window to execution `now` is fragile: gather time drifts day-over-day (worker contention, the +flow's own retries, queue latency), so consecutive `[now-24h, now]` windows either **overlap** +(events double-counted) or leave a **gap** (events counted in neither run). SC-002 explicitly +requires "no retention leakage/overlap", so the window must tile exactly. Flooring to midnight +UTC makes the daily series tile perfectly regardless of the random minute or execution jitter; +because the job runs at 02:XX, `window_end` (00:00 today) is always 2-3h settled in the past, +so every prior-day event has landed. A missed run simply yields an absent day rather than a +smeared one, and tests can pin a real boundary with `freezegun` instead of a moving `now`. The +field name `activity_24h` is retained (it is a 24h window); only the anchor is fixed. + **Decision**: Add a new windowed counter that posts to `/events/count-by/event` with an -`occurred` window (`since = now - 24h`, `until = now`) in the filter, alongside the existing -`event.name` filter. For `account.logged_in`: +`occurred` window (`since = window_start`, `until = window_end` as defined above) in the +filter, alongside the existing `event.name` filter. For `account.logged_in`: - `activity_24h.logins` = the windowed count of `infrahub.account.logged_in` events. - `activity_24h.unique_logins` = distinct accounts in the same window, obtained by counting by **resource** (`/events/count-by/resource`) over the windowed `logged_in` filter and @@ -68,9 +111,10 @@ rejected: heavier, and Prefect's count-by primitives do it server-side (Constitu **Decision**: `activity_24h.webhooks_fired_success` / `_failure` come from Prefect **flow runs** of the `webhook-process` flow (grounded: `@flow(name="webhook-process")` in -`webhook/tasks/process.py`) started within the trailing 24h, split by terminal state: +`webhook/tasks/process.py`) started within the **same `[window_start, window_end)` calendar-day +window as the event metrics** (Decision 3 anchor — not `now`), split by terminal state: `COMPLETED` ⇒ success; `FAILED` / `CRASHED` (and `TIMEDOUT`) ⇒ failure. Query via the Prefect -client's flow-run read API filtered by flow name and `start_time >= now - 24h`. +client's flow-run read API filtered by flow name and `start_time` in `[window_start, window_end)`. **Rationale**: Webhook delivery is a flow run, not an InfrahubEvent, so flow-run state is the correct signal. Webhook flow-run retention is 90 days (≫ 24h), so the window is always fully diff --git a/dev/specs/telemetry-collection-infp-589/spec.md b/dev/specs/telemetry-collection-infp-589/spec.md index ccd45aeb56e..06f0e9559a3 100644 --- a/dev/specs/telemetry-collection-infp-589/spec.md +++ b/dev/specs/telemetry-collection-infp-589/spec.md @@ -153,9 +153,14 @@ field is populated, and the payload is still sent/stored. ### Edge Cases -- **Event-retention leakage**: The trailing-24h window is far shorter than the - underlying event retention (7 days for events, 90 days for webhook flow runs), - so a correct window must never include retained-but-out-of-window records. +- **Event-retention leakage**: The 24h window is far shorter than the underlying + event retention (7 days for events, 90 days for webhook flow runs), so a correct + window must never include retained-but-out-of-window records. +- **Window anchoring vs. jittered schedule**: The daily job runs at a per-deployment + random minute. If the window were anchored to the job's execution time, day-over-day + execution drift would make consecutive windows overlap (double-count) or gap (miss + records). The window MUST therefore be anchored to a fixed calendar boundary (the + previous full UTC day), not to execution time, so daily snapshots tile exactly. - **Best-effort event counts**: Event dispatch can drop records, so activity counts are a trend signal, not a billing-grade exact count. This is acceptable and must be documented; success criteria for event metrics are framed around @@ -193,9 +198,12 @@ field is populated, and the payload is still sent/stored. events over the trailing 24h) and `activity_24h.unique_logins` (distinct accounts over the same window). [IFC-2823] - **FR-009**: The feature MUST NOT change the existing raw-vertex - `database.node_count.total` field, and MUST document the distinction between - the raw total, the managed-node (`corenode`) count, and the (future, blocked) - user-node count. [IFC-2821] + `database.node_count.total` field, and MUST document the distinction between the + three node metrics at the namespace level so they cannot later become synonyms: + `total` (raw vertices), `corenode` (all managed nodes — the `Core` + `Builtin` + + user-defined namespaces, this phase), and the future/blocked `user` (the + customer-facing subset that excludes the `Core` management namespace). They nest + strictly: `user` ⊆ `corenode` ⊆ `total`. [IFC-2821] - **FR-010**: When a metric's source fails, that field MUST be set to `null` while the rest of the payload is still emitted and stored; when a source succeeds with nothing to count, the field MUST be `0`, not `null`. [IFC-2820] @@ -219,8 +227,9 @@ field is populated, and the payload is still sent/stored. - **Telemetry payload**: The daily anonymous data structure Infrahub emits and stores. Carries a `payload_format` version identifier and nested sub-objects (`accounts`, `branches`, `database`, and the new `activity_24h`). -- **`activity_24h` object**: A new sub-object holding trailing-24h activity - counts: `logins`, `unique_logins`, `webhooks_fired_success`, +- **`activity_24h` object**: A new sub-object holding activity counts over the + previous full UTC calendar day (a 24h window anchored to a fixed boundary, not + to job-execution time): `logins`, `unique_logins`, `webhooks_fired_success`, `webhooks_fired_failure`. - **Account**: A user account with a status (active vs. non-active) used for `accounts.active`. @@ -240,9 +249,12 @@ field is populated, and the payload is still sent/stored. - **SC-001**: All in-scope fields are present on 100% of daily telemetry runs; a field is `null` only when its source genuinely failed, never when the source succeeded with nothing to count. -- **SC-002**: Event-derived metrics reflect exactly the trailing 24h, with no - leakage from records retained but outside the window — verified with fixtures - containing both in-window and out-of-window records. +- **SC-002**: Event-derived metrics reflect exactly a 24h window anchored to a + deterministic calendar boundary (the previous full UTC day), so consecutive + daily snapshots tile with no overlap and no gap — independent of the exact + (jittered) time the daily job runs — and with no leakage from records retained + but outside the window. Verified with fixtures containing both in-window and + out-of-window records. - **SC-003**: `database.node_count.corenode` matches an independently-computed fixture count exactly (±0). - **SC-004**: No existing telemetry field changes meaning, type, or name across @@ -259,9 +271,11 @@ field is populated, and the payload is still sent/stored. - Login activity is sourced from the event system (events are emitted after authentication and stored there); the graph database holds no last-login timestamp, so a windowed event query is the correct and only source. -- The trailing-24h window is comfortably shorter than the underlying retention - windows (7-day event retention, 90-day webhook flow-run retention), so all - in-window records are available at gather time. +- The 24h window is anchored to the previous full UTC calendar day (a fixed + boundary, not job-execution time) and is comfortably shorter than the underlying + retention windows (7-day event retention, 90-day webhook flow-run retention), so + all in-window records are available at gather time and consecutive daily + snapshots tile exactly. - Activity counts are best-effort trend signals (event dispatch can drop), not billing-grade exact figures; correctness is judged on windowing behavior, not against an external ground truth. diff --git a/dev/specs/telemetry-collection-infp-589/tasks.md b/dev/specs/telemetry-collection-infp-589/tasks.md index 5a54bb262da..d98d9cde1ba 100644 --- a/dev/specs/telemetry-collection-infp-589/tasks.md +++ b/dev/specs/telemetry-collection-infp-589/tasks.md @@ -76,15 +76,16 @@ repeat logins per account, and the existing `prefect.events.*` output is unchang ### Tests for User Story 1 (write first, must fail) ⚠️ -- [ ] T007 [P] [US1] Component test for windowed logins + unique_logins in `backend/tests/component/telemetry/test_task_manager.py`: with `freezegun` pinning "now", seed `account.logged_in` events in- and out-of-window (and repeat logins from one account); assert in-window-only `logins` and distinct-account `unique_logins`. +- [ ] T007 [P] [US1] Component test for windowed logins + unique_logins in `backend/tests/component/telemetry/test_task_manager.py`: with `freezegun` pinning "now" to an off-midnight time (e.g. 02:37 UTC), seed `account.logged_in` events placed relative to the previous-UTC-day boundary — inside the window, just before `window_start`, and just after `window_end` (and repeat logins from one account); assert in-window-only `logins`, distinct-account `unique_logins`, and that the boundary records are excluded (proves the window is anchored to midnight, not to `now`). - [ ] T008 [P] [US1] Component test for webhook success/failure split over 24h in `backend/tests/component/telemetry/test_task_manager.py`: seed terminal `webhook-process` flow runs (completed + failed) in- and out-of-window; assert correct counts and that non-terminal runs are excluded. - [ ] T009 [P] [US1] Regression test asserting `gather_prefect_events` output is unchanged (existing unwindowed tally still present and untouched). ### Implementation for User Story 1 -- [ ] T010 [US1] In `backend/infrahub/telemetry/task_manager.py`, add a NEW windowed event counter that posts to `/events/count-by/event` with an `occurred` window (`since = now - 24h`, `until = now`) plus the `event.name` filter — separate from `gather_prefect_events`, which stays untouched. +- [ ] T009b [US1] In `backend/infrahub/telemetry/task_manager.py` (or a small `telemetry/window.py` helper), add a deterministic window function returning `[window_start, window_end)` where `window_end = floor_to_midnight_utc(now)` and `window_start = window_end - 24h` (previous full UTC calendar day). All activity_24h queries use this — never raw `now`. +- [ ] T010 [US1] In `backend/infrahub/telemetry/task_manager.py`, add a NEW windowed event counter that posts to `/events/count-by/event` with an `occurred` window (`since = window_start`, `until = window_end` from T009b) plus the `event.name` filter — separate from `gather_prefect_events`, which stays untouched. - [ ] T011 [US1] In `task_manager.py`, add a windowed unique-account counter posting to `/events/count-by/resource` over the same `account.logged_in` window; the number of resource buckets (keyed by `infrahub.account.{account_id}`) is `unique_logins`. -- [ ] T012 [US1] In `task_manager.py`, add a `webhook-process` flow-run query over the trailing 24h, splitting terminal states into success (`COMPLETED`) and failure (`FAILED`/`CRASHED`/`TIMEDOUT`); non-terminal runs counted in neither. +- [ ] T012 [US1] In `task_manager.py`, add a `webhook-process` flow-run query over the same `[window_start, window_end)` window (T009b), splitting terminal states into success (`COMPLETED`) and failure (`FAILED`/`CRASHED`/`TIMEDOUT`); non-terminal runs counted in neither. - [ ] T013 [US1] In `task_manager.py`, add `gather_activity_24h(client) -> TelemetryActivity24hData` assembling the four counts, each obtained through the degradation helper so one failing source nulls only its own field. - [ ] T014 [US1] In `backend/infrahub/telemetry/tasks.py`, wire `activity_24h` into `gather_anonymous_telemetry_data` (gather via the Prefect client path; the object is always present). @@ -128,7 +129,7 @@ count, and assert `node_count["corenode"]` matches exactly (±0). ### Implementation for User Story 3 - [ ] T020 [US3] In `backend/infrahub/telemetry/database.py`, set `node_count["corenode"]` via `NodeManager.count(db, schema=InfrahubKind.NODE, branch=)`, wrapped so a failure sets `corenode=None` without affecting `node_count["total"]` or the existing graph-label keys (do NOT use raw `count_nodes(label=...)`). -- [ ] T021 [US3] Add/extend a docstring or module note distinguishing the three node metrics — raw vertex `total`, managed-node `corenode`, and the future (out-of-scope) `user` — without naming tickets/IDs in source. +- [ ] T021 [US3] Add/extend a docstring or module note distinguishing the three node metrics at the namespace level: `total` (raw vertices), `corenode` (all managed nodes — `Core` + `Builtin` + user-defined namespaces), and the future/out-of-scope `user` (customer-facing subset excluding the `Core` management namespace), noting they nest `user ⊆ corenode ⊆ total`. No tickets/IDs in source. **Checkpoint**: `corenode` exact and branch-correct; raw `total` preserved. From 4cc19b32d860ccf4708a6d3cd10a45fd67a64081 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Sun, 28 Jun 2026 21:35:53 +0200 Subject: [PATCH 07/75] scope(telemetry): INFP-589 pull checks + artifacts into Phase 1 (US5) User-directed scope expansion after a grilling pass. validator.started/passed/ failed -> activity_24h.checks_*, artifact.created/updated -> activity_24h. artifacts_*. Verified via get_all_events() that both event families already flow and are counted today, so they reuse the US1 windowed path unchanged (~1 event name + a parametrized test each) and serve the stated depth-of- adoption goal. Held in Phase 2 despite existing events (value needs correlation, not raw counts): PR governance (proposed_change.*), branch lifetime (branch.*), node churn (node.*). Recorded as sanctioned expansion in alignment-check.md. Adds FR-012/FR-013, US5 phase (T022-T023), renumbers US4->Phase7/Polish->Phase8. Co-Authored-By: Claude Opus 4.8 --- .../alignment-check.md | 27 ++++++- .../contracts/telemetry-payload.md | 10 +++ .../data-model.md | 9 +++ .../telemetry-collection-infp-589/plan.md | 11 ++- .../telemetry-collection-infp-589/research.md | 33 ++++++++ .../telemetry-collection-infp-589/spec.md | 60 +++++++++++++- .../telemetry-collection-infp-589/tasks.md | 78 +++++++++++++------ 7 files changed, 195 insertions(+), 33 deletions(-) diff --git a/dev/specs/telemetry-collection-infp-589/alignment-check.md b/dev/specs/telemetry-collection-infp-589/alignment-check.md index 2f5d76afb9b..005dadd3cc6 100644 --- a/dev/specs/telemetry-collection-infp-589/alignment-check.md +++ b/dev/specs/telemetry-collection-infp-589/alignment-check.md @@ -11,8 +11,10 @@ present in the ask — Jira/JPD items are referenced by ID only, so no web fetch ## 2. Verdict -✅ **ALIGNED** — no significant drift. The spec is a faithful, expanded restatement of the -PRD. Additions are necessary clarifications, not scope creep. +✅ **ALIGNED** — no *unintended* drift. The spec is a faithful, expanded restatement of the +PRD. Additions are either necessary clarifications or one **sanctioned, user-directed scope +expansion** (checks/artifacts metrics — see §6), explicitly recorded rather than silently +folded in. ## 3. Findings @@ -50,3 +52,24 @@ aligned with the PRD — they make existing requirements precise, they do not ad the future `user` metric excludes `Core`, so `user ⊆ corenode ⊆ total` strictly and they can never become synonyms (relevant because FR-011 forbids removing a shipped field). Updated in spec (FR-009), research (Decision 1), data-model, contract, and tasks (T021). + +## 6. Sanctioned scope expansion — checks & artifacts (2026-06-28, user-directed) + +| Category | PRD reference | Spec reference | Description | +|----------|---------------|----------------|-------------| +| added (approved) | **not in PRD** (came from the JPD card's Phase 2 list, not the handoff) | FR-012, FR-013, US5, `activity_24h.checks_*` / `artifacts_*` | Pulled `validator.started/passed/failed` → `checks_*` and `artifact.created/updated` → `artifacts_*` into Phase 1. | + +**Why this is NOT unresolved drift**: The user explicitly directed this after their own grilling +pass. It is a deliberate, recorded expansion — the alignment phase exists to surface exactly +this kind of divergence rather than let it pass unnoticed, and here it is surfaced and approved. + +**Why it is safe / cheap**: Both event families are **already emitted and counted today** +(verified via `get_all_events()`), so they reuse the US1 windowed event path unchanged (one more +event name per metric + a parametrized test). They serve the already-stated Phase 1 +"depth-of-adoption" goal. + +**Boundary discipline applied**: Events that exist but whose *value needs correlation* were +deliberately **held in Phase 2** to avoid free-metric creep into permanent (FR-011) contract +surface — PR governance (`proposed_change.*`, needs review↔merge correlation), branch lifetime +(`branch.*`, needs time-to-merge correlation), and node churn (`node.*`, no standalone Phase 1 +value). Recorded in spec "Out of Scope" and research Decision 9. diff --git a/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md b/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md index 6a95d739873..ddc323bcaeb 100644 --- a/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md +++ b/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md @@ -46,6 +46,11 @@ data mart) is forward-compatible: it ignores unknown fields, so additive changes "activity_24h": { // NEW object — previous full UTC calendar day [00:00, 00:00) "logins": 19, // int | null "unique_logins": 6, // int | null + "checks_started": 88, // int | null + "checks_passed": 80, // int | null + "checks_failed": 8, // int | null + "artifacts_created": 14, // int | null + "artifacts_updated": 31, // int | null "webhooks_fired_success": 41, // int | null "webhooks_fired_failure": 2 // int | null } @@ -66,6 +71,11 @@ deterministic boundary (not gather-time `now`) so daily snapshots tile exactly. | `database.node_count.corenode` | `NodeManager.count(CoreNode)` | current | `0` | `null` | | `activity_24h.logins` | Prefect `account.logged_in` events, windowed | prev. UTC day | `0` | `null` | | `activity_24h.unique_logins` | Prefect count-by-resource on login events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.checks_started` | Prefect `validator.started` events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.checks_passed` | Prefect `validator.passed` events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.checks_failed` | Prefect `validator.failed` events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.artifacts_created` | Prefect `artifact.created` events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.artifacts_updated` | Prefect `artifact.updated` events, windowed | prev. UTC day | `0` | `null` | | `activity_24h.webhooks_fired_success` | Prefect `webhook-process` flow runs, `COMPLETED` | prev. UTC day | `0` | `null` | | `activity_24h.webhooks_fired_failure` | Prefect `webhook-process` flow runs, `FAILED`/`CRASHED`/`TIMEDOUT` | prev. UTC day | `0` | `null` | diff --git a/dev/specs/telemetry-collection-infp-589/data-model.md b/dev/specs/telemetry-collection-infp-589/data-model.md index b8e1904bcbb..e391ae398d9 100644 --- a/dev/specs/telemetry-collection-infp-589/data-model.md +++ b/dev/specs/telemetry-collection-infp-589/data-model.md @@ -26,9 +26,18 @@ exactly (no overlap, no gap) despite the jittered cron minute and execution drif |---------------------------|---------------|---------------------------------------------------------|-------|---------| | `logins` | `int \| None` | `account.logged_in` events in the windowed day | `0` | `null` | | `unique_logins` | `int \| None` | Distinct `account_id` among those logins (same window) | `0` | `null` | +| `checks_started` | `int \| None` | `validator.started` events in-window | `0` | `null` | +| `checks_passed` | `int \| None` | `validator.passed` events in-window | `0` | `null` | +| `checks_failed` | `int \| None` | `validator.failed` events in-window | `0` | `null` | +| `artifacts_created` | `int \| None` | `artifact.created` events in-window | `0` | `null` | +| `artifacts_updated` | `int \| None` | `artifact.updated` events in-window | `0` | `null` | | `webhooks_fired_success` | `int \| None` | `webhook-process` flow runs in-window ending `COMPLETED`| `0` | `null` | | `webhooks_fired_failure` | `int \| None` | `webhook-process` flow runs in-window ending `FAILED`/`CRASHED`/`TIMEDOUT` | `0` | `null` | +The five check/artifact fields are derived from events that are **already emitted and counted +today** (windowless) via `get_all_events()`; they reuse the windowed event-count path +unchanged — each is one more event name in the same query. They serve "depth of adoption". + Each field is isolated: one failing source nulls only its own field. A `webhook-process` run that started in-window but is still non-terminal (`PENDING`/`RUNNING`/`SCHEDULED`) at gather time is counted in neither success nor failure — only terminal outcomes are tallied (a diff --git a/dev/specs/telemetry-collection-infp-589/plan.md b/dev/specs/telemetry-collection-infp-589/plan.md index 8f67ad009f0..db9f9f58b77 100644 --- a/dev/specs/telemetry-collection-infp-589/plan.md +++ b/dev/specs/telemetry-collection-infp-589/plan.md @@ -9,8 +9,10 @@ Extend Infrahub's daily anonymous telemetry payload with additive, backwards-compatible metrics: account adoption (`accounts.active`, `accounts.groups`), open-branch count (`branches.active`), a branch/temporal-correct managed-node count -(`database.node_count.corenode`), and a new trailing-24h `activity_24h` object -(`logins`, `unique_logins`, `webhooks_fired_success`, `webhooks_fired_failure`). +(`database.node_count.corenode`), and a new calendar-day-windowed `activity_24h` object +(`logins`, `unique_logins`, `checks_started/passed/failed`, `artifacts_created/updated`, +`webhooks_fired_success`, `webhooks_fired_failure`). The check/artifact metrics are derived +from events that already flow today, harvested cheaply on the same windowed path. Technical approach: add the new fields to the existing Pydantic payload models; add gather functions that use the standard branch-safe `NodeManager.count` path for node-based metrics @@ -45,8 +47,9 @@ isolation: one failing source must not drop the payload. Event metrics must refl 24h window anchored to a deterministic calendar boundary (previous full UTC day), not to job-execution time, so daily snapshots tile with no overlap/gap despite the jittered cron. -**Scale/Scope**: ~9 new payload fields across 2 new sub-models + 2 extended sub-models; -~3 new gather functions; 1 degradation helper; 1 constant bump. Producer-only. +**Scale/Scope**: ~13 new payload fields across 2 new sub-models + 2 extended sub-models; +~3 new gather functions; 1 windowed-event counter (reused for logins + 5 check/artifact +metrics); 1 degradation helper; 1 constant bump. Producer-only. ## Constitution Check diff --git a/dev/specs/telemetry-collection-infp-589/research.md b/dev/specs/telemetry-collection-infp-589/research.md index aef008cd6af..516a1c6d87e 100644 --- a/dev/specs/telemetry-collection-infp-589/research.md +++ b/dev/specs/telemetry-collection-infp-589/research.md @@ -196,6 +196,39 @@ in-window vs out-of-window fixtures are unambiguous and non-flaky. outside a flow context, `get_run_logger` is handled per the allowed `testing-python.md` pattern (return a stdlib logger), not via general mocking. +## Decision 9 — Phase split by "is the event already flowing?" (checks & artifacts pulled in) + +**Decision**: Once the windowed event path exists (Decision 3), any metric derived from an +**already-emitted, already-counted** event costs ~one event name + a parametrized test. So the +Phase 1/2 boundary for event-derived metrics is drawn on *"is the event already flowing and is +a raw windowed count the valuable signal?"* — not on the original card's labelling. Verified +against `get_all_events()`: + +- **Pulled into Phase 1** (events emitted & counted today; raw per-period count *is* the + depth-of-adoption signal): `validator.started/passed/failed` → `checks_*`; + `artifact.created/updated` → `artifacts_*`. Near-zero marginal cost, serves a stated Phase 1 + goal. +- **Held in Phase 2 although the events exist** (raw count is cheap but not the valuable + signal; a bare count would be permanent contract surface per FR-011 with no clear Phase 1 + use): + - PR governance — `proposed_change.*` exist, but "merged without review" needs per-PR + review↔merge correlation. + - Branch lifetime — `branch.created/merged/deleted` exist, but time-to-merge needs durable + per-branch correlation. + - Node churn — `node.created/updated/deleted` exist, but raw churn counts lack standalone + Phase 1 value. +- **Stay Phase 2 — no events at all**: generators/transformations (no `generator.*`/`transform.*` + events), distinct API tokens (no token identity in events), CLI/MCP/Sync (greenfield SDK + instrumentation), licensing cores/RAM (product-scope decision). + +**Rationale**: This keeps Phase 1 disciplined (every field must serve a stated goal, not just +be cheap) while harvesting the genuine free wins the windowing enabler unlocks. The discipline +matters because FR-011 makes every shipped field unremovable. + +**Scope note (divergence from handoff PRD)**: checks/artifacts were not in the handoff PRD's +in-scope FR list; they are a deliberate, user-directed expansion recorded in +`alignment-check.md` §5. The events being verified-present is what makes the expansion safe. + ## Decision 8 — Webhook run terminality & count cost (secondary) **Webhook non-terminal runs**: a `webhook-process` run that started in-window but is still diff --git a/dev/specs/telemetry-collection-infp-589/spec.md b/dev/specs/telemetry-collection-infp-589/spec.md index 06f0e9559a3..6e5a6778e02 100644 --- a/dev/specs/telemetry-collection-infp-589/spec.md +++ b/dev/specs/telemetry-collection-infp-589/spec.md @@ -151,6 +151,38 @@ field is populated, and the payload is still sent/stored. --- +### User Story 5 - Depth-of-adoption activity: checks & artifacts (Priority: P2) + +As the OpsMill data team, I need the daily payload to report how many validation +checks ran (and their pass/fail outcomes) and how many artifacts were generated +over the same trailing-24h window, so I can measure *depth* of adoption — not just +that a deployment exists, but that its core workflows are actively used. + +**Why this priority**: These ride entirely on the windowed event path delivered by +User Story 1 — the underlying events (`validator.started/passed/failed`, +`artifact.created/updated`) are already emitted and counted today, so the marginal +cost is one additional event name per metric plus a parametrized test. They serve +the depth-of-adoption goal directly, making them a near-zero-cost extension of the +enabler rather than new scope of their own. + +**Independent Test**: Seed validator and artifact events inside and outside the +24h window, trigger the gather, and assert the check/artifact counts reflect +exactly the in-window events. + +**Acceptance Scenarios**: + +1. **Given** validator events (`started`/`passed`/`failed`) inside the window, + **When** the payload is gathered, **Then** `activity_24h.checks_started`, + `checks_passed`, and `checks_failed` equal the in-window counts of each. +2. **Given** artifact events (`created`/`updated`) inside the window, **When** the + payload is gathered, **Then** `activity_24h.artifacts_created` and + `artifacts_updated` equal the in-window counts of each. +3. **Given** a deployment with no such events in the window, **When** the payload + is gathered, **Then** each of these counts is `0` (not `null`, not absent), and + on a source failure the affected field is `null`. + +--- + ### Edge Cases - **Event-retention leakage**: The 24h window is far shorter than the underlying @@ -209,6 +241,13 @@ field is populated, and the payload is still sent/stored. succeeds with nothing to count, the field MUST be `0`, not `null`. [IFC-2820] - **FR-011**: All changes MUST be additive. No existing field may change its meaning, type, or name. Deprecation is permitted; removal is not. +- **FR-012**: The payload MUST report `activity_24h.checks_started`, + `activity_24h.checks_passed`, and `activity_24h.checks_failed` as the windowed + counts of `validator.started`, `validator.passed`, and `validator.failed` events + respectively, via the same windowed event path as FR-007/FR-008. [INFP-589, depth-of-adoption] +- **FR-013**: The payload MUST report `activity_24h.artifacts_created` and + `activity_24h.artifacts_updated` as the windowed counts of `artifact.created` and + `artifact.updated` events respectively, via the same windowed event path. [INFP-589, depth-of-adoption] ### Governance Requirement @@ -229,7 +268,9 @@ field is populated, and the payload is still sent/stored. (`accounts`, `branches`, `database`, and the new `activity_24h`). - **`activity_24h` object**: A new sub-object holding activity counts over the previous full UTC calendar day (a 24h window anchored to a fixed boundary, not - to job-execution time): `logins`, `unique_logins`, `webhooks_fired_success`, + to job-execution time): `logins`, `unique_logins`, `checks_started`, + `checks_passed`, `checks_failed`, `artifacts_created`, `artifacts_updated`, + `webhooks_fired_success`, `webhooks_fired_failure`. - **Account**: A user account with a status (active vs. non-active) used for `accounts.active`. @@ -288,9 +329,20 @@ field is populated, and the payload is still sent/stored. - `database.node_count.user` (FR-004 / IFC-2825) — blocked on a product decision about which namespaces to include; explicitly excluded from this feature. -- All Phase 2 telemetry items: licensing (cores/RAM), distinct API-token usage, - generators/transformations adoption, branch lifetime, PR governance, - CLI/MCP/Sync adoption, and GraphQL/REST metrics. +- Remaining Phase 2 telemetry items: licensing (cores/RAM), distinct API-token + usage, generators/transformations adoption, branch lifetime, PR governance, + CLI/MCP/Sync adoption, and GraphQL/REST metrics. (Checks and artifacts, formerly + considered Phase 2, are pulled into Phase 1 — see FR-012/FR-013 — because their + events already flow and serve a stated Phase 1 goal.) +- **Held in Phase 2 even though their events already flow** (aggregate counts would + be cheap, but the *valuable* signal needs correlation, not raw counts, so a bare + count would be permanent contract surface — FR-011 — without clear Phase 1 value): + - **PR governance** — `proposed_change.*` events exist, but the useful metric + ("merged without review") needs per-PR review↔merge correlation. + - **Branch lifetime** — `branch.created/merged/deleted` events exist, but the + useful metric (time-to-merge) needs durable per-branch correlation. + - **Node churn** — `node.created/updated/deleted` events exist, but raw 24h churn + counts have no clear standalone Phase 1 use. - Dashboard rendering and any consumer-side visualization (INFP-550 / SA-184). - Redefining or altering the existing `database.node_count.total` raw-vertex metric. diff --git a/dev/specs/telemetry-collection-infp-589/tasks.md b/dev/specs/telemetry-collection-infp-589/tasks.md index d98d9cde1ba..8601ad46e49 100644 --- a/dev/specs/telemetry-collection-infp-589/tasks.md +++ b/dev/specs/telemetry-collection-infp-589/tasks.md @@ -53,7 +53,7 @@ full-payload resilience test that runs last, since it asserts every in-scope fie - [ ] T003 Bump `TELEMETRY_VERSION` in `backend/infrahub/telemetry/constants.py` from `"20250318"` to `"20260628"` (this also advances `DEFAULT_PAYLOAD_FORMAT`). - [ ] T004 In `backend/infrahub/telemetry/models.py`, add the additive payload models: - `TelemetryAccountData` with `active: int | None` and `groups: int | None`. - - `TelemetryActivity24hData` with `logins`, `unique_logins`, `webhooks_fired_success`, `webhooks_fired_failure`, all `int | None`. + - `TelemetryActivity24hData` with `logins`, `unique_logins`, `checks_started`, `checks_passed`, `checks_failed`, `artifacts_created`, `artifacts_updated`, `webhooks_fired_success`, `webhooks_fired_failure`, all `int | None`. - Extend `TelemetryBranchData` with `active: int | None = None` (keep `total: int`). - Widen `TelemetryDatabaseData.node_count` value type to `dict[str, int | None]`. - Add `accounts: TelemetryAccountData` and `activity_24h: TelemetryActivity24hData` to `TelemetryData` (always-present objects; per-field nullability). @@ -86,7 +86,7 @@ repeat logins per account, and the existing `prefect.events.*` output is unchang - [ ] T010 [US1] In `backend/infrahub/telemetry/task_manager.py`, add a NEW windowed event counter that posts to `/events/count-by/event` with an `occurred` window (`since = window_start`, `until = window_end` from T009b) plus the `event.name` filter — separate from `gather_prefect_events`, which stays untouched. - [ ] T011 [US1] In `task_manager.py`, add a windowed unique-account counter posting to `/events/count-by/resource` over the same `account.logged_in` window; the number of resource buckets (keyed by `infrahub.account.{account_id}`) is `unique_logins`. - [ ] T012 [US1] In `task_manager.py`, add a `webhook-process` flow-run query over the same `[window_start, window_end)` window (T009b), splitting terminal states into success (`COMPLETED`) and failure (`FAILED`/`CRASHED`/`TIMEDOUT`); non-terminal runs counted in neither. -- [ ] T013 [US1] In `task_manager.py`, add `gather_activity_24h(client) -> TelemetryActivity24hData` assembling the four counts, each obtained through the degradation helper so one failing source nulls only its own field. +- [ ] T013 [US1] In `task_manager.py`, add `gather_activity_24h(client) -> TelemetryActivity24hData` assembling the login + webhook counts (US1 fields), each obtained through the degradation helper so one failing source nulls only its own field. (US5 extends this same function with the check/artifact counts.) - [ ] T014 [US1] In `backend/infrahub/telemetry/tasks.py`, wire `activity_24h` into `gather_anonymous_telemetry_data` (gather via the Prefect client path; the object is always present). **Checkpoint**: `activity_24h` present and windowed; existing event output intact. MVP testable. @@ -135,13 +135,38 @@ count, and assert `node_count["corenode"]` matches exactly (±0). --- -## Phase 6: User Story 4 — Resilient payload (Priority: P1) +## Phase 6: User Story 5 — Depth-of-adoption: checks & artifacts (Priority: P2) + +**Goal**: Emit `activity_24h.checks_started/passed/failed` and +`activity_24h.artifacts_created/updated` from events that already flow today, reusing the US1 +windowed event path unchanged. + +> Rides entirely on US1's windowed counter (T009b/T010). Each metric is one more event name in +> the same query — verified present via `get_all_events()`: `validator.started/passed/failed`, +> `artifact.created/updated`. + +**Independent Test**: Seed `validator.*` and `artifact.*` events in- and out-of-window; assert +each count reflects exactly the in-window events; assert genuine-empty → `0`. + +### Tests for User Story 5 (write first, must fail) ⚠️ + +- [ ] T022 [P] [US5] Component test in `backend/tests/component/telemetry/test_task_manager.py` (parametrized off the US1 windowing fixture): seed `validator.started/passed/failed` and `artifact.created/updated` events in- and out-of-window; assert `checks_started/passed/failed` and `artifacts_created/updated` equal the in-window counts and that out-of-window events are excluded. + +### Implementation for User Story 5 + +- [ ] T023 [US5] Extend the windowed event counter (T010) to also count `validator.started`, `validator.passed`, `validator.failed`, `artifact.created`, `artifact.updated`, and extend `gather_activity_24h` (T013) to populate the five new fields, each through the degradation helper (per-field null isolation). No change to `gather_prefect_events`. + +**Checkpoint**: Depth-of-adoption check/artifact metrics present and windowed. + +--- + +## Phase 7: User Story 4 — Resilient payload (Priority: P1) **Goal**: Guarantee the cross-cutting resilience contract end-to-end: every in-scope field is present; a failing source yields `null` (not a dropped payload); a genuine empty yields `0`. > The mechanism (degradation helper) is delivered in Foundational and consumed by each story -> above. This phase validates the whole payload, so it runs after US1–US3 are wired. +> above. This phase validates the whole payload, so it runs after US1–US3 and US5 are wired. **Independent Test**: Run the gather flow; assert all in-scope fields present. Force one source to fail (inject a failing collaborator/fixture — no mock); assert that field is `null`, all @@ -149,23 +174,23 @@ others populated, and the payload is still built and stored. Assert genuine-empt ### Tests for User Story 4 (write first, must fail) ⚠️ -- [ ] T022 [US4] Component test in `backend/tests/component/telemetry/test_tasks.py`: run `gather_anonymous_telemetry_data` on a healthy stack and assert presence of `accounts.{active,groups}`, `branches.active`, `database.node_count.corenode`, and `activity_24h.{logins,unique_logins,webhooks_fired_success,webhooks_fired_failure}`. -- [ ] T023 [US4] Resilience test in `backend/tests/component/telemetry/test_tasks.py`: make one source fail via an injected failing collaborator/fixture (no mock); assert that field is `null`, every other field is populated, and the snapshot is still stored. Add a genuine-empty case asserting `0`, not `null`. +- [ ] T024 [US4] Component test in `backend/tests/component/telemetry/test_tasks.py`: run `gather_anonymous_telemetry_data` on a healthy stack and assert presence of `accounts.{active,groups}`, `branches.active`, `database.node_count.corenode`, and all `activity_24h` fields (`logins`, `unique_logins`, `checks_started/passed/failed`, `artifacts_created/updated`, `webhooks_fired_success/failure`). +- [ ] T025 [US4] Resilience test in `backend/tests/component/telemetry/test_tasks.py`: make one source fail via an injected failing collaborator/fixture (no mock); assert that field is `null`, every other field is populated, and the snapshot is still stored. Add a genuine-empty case asserting `0`, not `null`. ### Implementation for User Story 4 -- [ ] T024 [US4] Audit `gather_anonymous_telemetry_data` in `backend/infrahub/telemetry/tasks.py` to ensure every new metric (accounts, branches.active, corenode, each activity_24h field) is gathered through the degradation helper — no new metric can raise out of the orchestrator. If the current wiring doesn't expose a clean no-mock failure seam for T023, introduce one (e.g. an injectable gather collaborator) following backend component-design DI rules. +- [ ] T026 [US4] Audit `gather_anonymous_telemetry_data` in `backend/infrahub/telemetry/tasks.py` to ensure every new metric (accounts, branches.active, corenode, each activity_24h field incl. checks/artifacts) is gathered through the degradation helper — no new metric can raise out of the orchestrator. If the current wiring doesn't expose a clean no-mock failure seam for T025, introduce one (e.g. an injectable gather collaborator) following backend component-design DI rules. **Checkpoint**: Whole payload resilient; one failing source never drops the rest. --- -## Phase 7: Polish & Cross-Cutting Concerns +## Phase 8: Polish & Cross-Cutting Concerns -- [ ] T025 [P] Run the telemetry suites: `uv run pytest backend/tests/unit/telemetry backend/tests/component/telemetry -q` (set `DOCKER_HOST` for component tests). -- [ ] T026 [P] `uv run invoke format lint` and resolve any findings in the telemetry module. -- [ ] T027 Run the `quickstart.md` validation steps end-to-end and confirm `payload_format == "20260628"` in a stored snapshot. -- [ ] T028 **Governance gate (GR-001)** — before merge/release, confirm with the cloud-processor owner and the data-mart owner that the receiver tolerates the `payload_format` bump, ignores unknown fields, and tolerates `null` values (including `corenode` inside `node_count`). Record the confirmation on the PR / tracking ticket. (Process task, not code.) +- [ ] T027 [P] Run the telemetry suites: `uv run pytest backend/tests/unit/telemetry backend/tests/component/telemetry -q` (set `DOCKER_HOST` for component tests). +- [ ] T028 [P] `uv run invoke format lint` and resolve any findings in the telemetry module. +- [ ] T029 Run the `quickstart.md` validation steps end-to-end and confirm `payload_format == "20260628"` in a stored snapshot. +- [ ] T030 **Governance gate (GR-001)** — before merge/release, confirm with the cloud-processor owner and the data-mart owner that the receiver tolerates the `payload_format` bump, ignores unknown fields, and tolerates `null` values (including `corenode` inside `node_count`). Record the confirmation on the PR / tracking ticket. (Process task, not code.) --- @@ -176,23 +201,25 @@ others populated, and the payload is still built and stored. Assert genuine-empt - **Setup (Phase 1)**: no dependencies. - **Foundational (Phase 2)**: depends on Setup; **blocks all stories** (models, helper, version). - **US1 (Phase 3)**, **US2 (Phase 4)**, **US3 (Phase 5)**: each depends only on Foundational; mutually independent (different gather functions / files), so parallelizable across developers. -- **US4 (Phase 6)**: depends on US1–US3 being wired (it asserts the full payload). -- **Polish (Phase 7)**: depends on all desired stories complete. +- **US5 (Phase 6)**: depends on US1 (reuses its windowed counter); otherwise independent. +- **US4 (Phase 7)**: depends on US1–US3 and US5 being wired (it asserts the full payload). +- **Polish (Phase 8)**: depends on all desired stories complete. ### Within Each User Story - Tests are written first and must fail before implementation. -- US1: windowed counters (T010–T012) → assembler (T013) → orchestrator wiring (T014). +- US1: window helper (T009b) → windowed counters (T010–T012) → assembler (T013) → orchestrator wiring (T014). - US2: gather function (T017) → orchestrator wiring (T018). - US3: db count (T020) → docs note (T021). +- US5: extend the windowed counter + assembler (T023) after US1's T010/T013 exist. ### Parallel Opportunities - T001 / T002 (Setup) in parallel. - T005 (helper test) parallel with T003/T004 (constant + models). - US1 test tasks T007/T008/T009 in parallel; US2 T015/T016 in parallel. -- With capacity: US1, US2, US3 proceed in parallel once Foundational is done. -- Polish T025/T026 in parallel. +- With capacity: US1, US2, US3 proceed in parallel once Foundational is done; US5 follows US1. +- Polish T027/T028 in parallel. --- @@ -219,11 +246,12 @@ Task: "Regression test gather_prefect_events unchanged" ### Incremental Delivery 1. Setup + Foundational → contract + mechanism ready. -2. US1 → trailing-24h activity (MVP). +2. US1 → calendar-day-windowed activity (MVP). 3. US2 → account/branch adoption. 4. US3 → branch-correct scaling count. -5. US4 → full-payload resilience guarantee. -6. Polish → suite green, lint clean, GR-001 governance confirmed. +5. US5 → depth-of-adoption checks/artifacts (rides on US1). +6. US4 → full-payload resilience guarantee. +7. Polish → suite green, lint clean, GR-001 governance confirmed. --- @@ -232,6 +260,10 @@ Task: "Regression test gather_prefect_events unchanged" - `[P]` = different files, no incomplete-task dependency. - `[Story]` label maps each task to a user story for traceability (this file only — never in source). - Verify each test fails before implementing. -- US4's value is P1, but its full-payload assertion depends on US1–US3, so it is scheduled last. -- Out of scope (do not implement): `database.node_count.user` (blocked), Phase 2 metrics, - dashboards, redefining `node_count.total`, persisting logins in Neo4j. +- US4's value is P1, but its full-payload assertion depends on US1–US3 + US5, so it is scheduled last. +- US5 (checks/artifacts) is a user-directed pull-in from Phase 2 — cheap because the events + already flow; see `alignment-check.md` §5 for the sanctioned-scope-expansion record. +- Out of scope (do not implement): `database.node_count.user` (blocked); PR/branch/node-churn + aggregate counts (events exist but value needs correlation — held in Phase 2); remaining + Phase 2 metrics (generators/transforms, tokens, CLI/MCP/Sync, licensing); dashboards; + redefining `node_count.total`; persisting logins in Neo4j. From e4ea26d9abd16c75a72b86a8cc374e713f01d227 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Sun, 28 Jun 2026 21:51:33 +0200 Subject: [PATCH 08/75] scope(telemetry): INFP-589 add branch lifecycle counts to Phase 1 (US5) Conceded a misclassification: branch *lifecycle counts* (created/merged/deleted) need no correlation and are a first-class adoption signal for the branch-based workflow, so they belong with checks/artifacts by the same already-flowing-event rule. Only branch *lifetime* (duration) needs correlation and stays Phase 2. Adds FR-014 + activity_24h.branches_created/merged/deleted (verified present via get_all_events). Held on merit: node churn (machine-dominated/noisy), branch rebased/migrated (low-signal), PR merged-without-review + branch lifetime (need correlation). ~16 new payload fields total. Co-Authored-By: Claude Opus 4.8 --- .../alignment-check.md | 33 ++++++----- .../contracts/telemetry-payload.md | 6 ++ .../data-model.md | 9 ++- .../telemetry-collection-infp-589/plan.md | 9 +-- .../telemetry-collection-infp-589/research.md | 28 ++++++---- .../telemetry-collection-infp-589/spec.md | 56 ++++++++++++------- .../telemetry-collection-infp-589/tasks.md | 40 +++++++------ 7 files changed, 113 insertions(+), 68 deletions(-) diff --git a/dev/specs/telemetry-collection-infp-589/alignment-check.md b/dev/specs/telemetry-collection-infp-589/alignment-check.md index 005dadd3cc6..047c29bf5c8 100644 --- a/dev/specs/telemetry-collection-infp-589/alignment-check.md +++ b/dev/specs/telemetry-collection-infp-589/alignment-check.md @@ -53,23 +53,30 @@ aligned with the PRD — they make existing requirements precise, they do not ad never become synonyms (relevant because FR-011 forbids removing a shipped field). Updated in spec (FR-009), research (Decision 1), data-model, contract, and tasks (T021). -## 6. Sanctioned scope expansion — checks & artifacts (2026-06-28, user-directed) +## 6. Sanctioned scope expansion — checks, artifacts & branch lifecycle (2026-06-28, user-directed) | Category | PRD reference | Spec reference | Description | |----------|---------------|----------------|-------------| -| added (approved) | **not in PRD** (came from the JPD card's Phase 2 list, not the handoff) | FR-012, FR-013, US5, `activity_24h.checks_*` / `artifacts_*` | Pulled `validator.started/passed/failed` → `checks_*` and `artifact.created/updated` → `artifacts_*` into Phase 1. | +| added (approved) | **not in PRD** (came from the JPD card's Phase 2 list, not the handoff) | FR-012, FR-013, FR-014, US5, `activity_24h.checks_*` / `artifacts_*` / `branches_*` | Pulled `validator.started/passed/failed` → `checks_*`, `artifact.created/updated` → `artifacts_*`, and `branch.created/merged/deleted` → `branches_*` into Phase 1. | -**Why this is NOT unresolved drift**: The user explicitly directed this after their own grilling -pass. It is a deliberate, recorded expansion — the alignment phase exists to surface exactly -this kind of divergence rather than let it pass unnoticed, and here it is surfaced and approved. +**Why this is NOT unresolved drift**: The user explicitly directed this across two review +rounds (checks/artifacts, then branch-lifecycle counts after challenging the cost). It is a +deliberate, recorded expansion — the alignment phase exists to surface exactly this kind of +divergence rather than let it pass unnoticed, and here it is surfaced and approved. -**Why it is safe / cheap**: Both event families are **already emitted and counted today** +**Why it is safe / cheap**: All three event families are **already emitted and counted today** (verified via `get_all_events()`), so they reuse the US1 windowed event path unchanged (one more event name per metric + a parametrized test). They serve the already-stated Phase 1 -"depth-of-adoption" goal. - -**Boundary discipline applied**: Events that exist but whose *value needs correlation* were -deliberately **held in Phase 2** to avoid free-metric creep into permanent (FR-011) contract -surface — PR governance (`proposed_change.*`, needs review↔merge correlation), branch lifetime -(`branch.*`, needs time-to-merge correlation), and node churn (`node.*`, no standalone Phase 1 -value). Recorded in spec "Out of Scope" and research Decision 9. +"depth-of-adoption" goal; branch create/merge/delete activity is an especially direct adoption +signal for the branch-based workflow. + +**Boundary discipline applied** — events that exist but were deliberately **held in Phase 2**, +to keep permanent (FR-011) contract surface to clean standalone signals: +- **PR "merged-without-review"** (`proposed_change.*`) — needs per-PR review↔merge correlation. +- **Branch *lifetime*** (create→merge duration) — needs durable per-branch correlation. (The + lifecycle *counts* are in scope; only the duration is deferred.) +- **Node churn** (`node.*`) — `node.updated` fires on every attribute mutation incl. automated + writes, so the count is machine-dominated (held on signal quality, not cost). +- **Branch `rebased`/`migrated` counts** — maintenance/automation-driven, lower-signal. + +Recorded in spec "Out of Scope", research Decision 9, and tasks Notes. diff --git a/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md b/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md index ddc323bcaeb..f41e7ec0f80 100644 --- a/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md +++ b/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md @@ -51,6 +51,9 @@ data mart) is forward-compatible: it ignores unknown fields, so additive changes "checks_failed": 8, // int | null "artifacts_created": 14, // int | null "artifacts_updated": 31, // int | null + "branches_created": 9, // int | null + "branches_merged": 5, // int | null + "branches_deleted": 4, // int | null "webhooks_fired_success": 41, // int | null "webhooks_fired_failure": 2 // int | null } @@ -76,6 +79,9 @@ deterministic boundary (not gather-time `now`) so daily snapshots tile exactly. | `activity_24h.checks_failed` | Prefect `validator.failed` events, windowed | prev. UTC day | `0` | `null` | | `activity_24h.artifacts_created` | Prefect `artifact.created` events, windowed | prev. UTC day | `0` | `null` | | `activity_24h.artifacts_updated` | Prefect `artifact.updated` events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.branches_created` | Prefect `branch.created` events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.branches_merged` | Prefect `branch.merged` events, windowed | prev. UTC day | `0` | `null` | +| `activity_24h.branches_deleted` | Prefect `branch.deleted` events, windowed | prev. UTC day | `0` | `null` | | `activity_24h.webhooks_fired_success` | Prefect `webhook-process` flow runs, `COMPLETED` | prev. UTC day | `0` | `null` | | `activity_24h.webhooks_fired_failure` | Prefect `webhook-process` flow runs, `FAILED`/`CRASHED`/`TIMEDOUT` | prev. UTC day | `0` | `null` | diff --git a/dev/specs/telemetry-collection-infp-589/data-model.md b/dev/specs/telemetry-collection-infp-589/data-model.md index e391ae398d9..80b38eb6daa 100644 --- a/dev/specs/telemetry-collection-infp-589/data-model.md +++ b/dev/specs/telemetry-collection-infp-589/data-model.md @@ -31,12 +31,17 @@ exactly (no overlap, no gap) despite the jittered cron minute and execution drif | `checks_failed` | `int \| None` | `validator.failed` events in-window | `0` | `null` | | `artifacts_created` | `int \| None` | `artifact.created` events in-window | `0` | `null` | | `artifacts_updated` | `int \| None` | `artifact.updated` events in-window | `0` | `null` | +| `branches_created` | `int \| None` | `branch.created` events in-window | `0` | `null` | +| `branches_merged` | `int \| None` | `branch.merged` events in-window | `0` | `null` | +| `branches_deleted` | `int \| None` | `branch.deleted` events in-window | `0` | `null` | | `webhooks_fired_success` | `int \| None` | `webhook-process` flow runs in-window ending `COMPLETED`| `0` | `null` | | `webhooks_fired_failure` | `int \| None` | `webhook-process` flow runs in-window ending `FAILED`/`CRASHED`/`TIMEDOUT` | `0` | `null` | -The five check/artifact fields are derived from events that are **already emitted and counted -today** (windowless) via `get_all_events()`; they reuse the windowed event-count path +The eight check/artifact/branch fields are derived from events that are **already emitted and +counted today** (windowless) via `get_all_events()`; they reuse the windowed event-count path unchanged — each is one more event name in the same query. They serve "depth of adoption". +Branch lifecycle *counts* are in scope; branch *lifetime* (create→merge duration) is not — it +needs per-branch correlation. Each field is isolated: one failing source nulls only its own field. A `webhook-process` run that started in-window but is still non-terminal (`PENDING`/`RUNNING`/`SCHEDULED`) at gather diff --git a/dev/specs/telemetry-collection-infp-589/plan.md b/dev/specs/telemetry-collection-infp-589/plan.md index db9f9f58b77..6de17767c3f 100644 --- a/dev/specs/telemetry-collection-infp-589/plan.md +++ b/dev/specs/telemetry-collection-infp-589/plan.md @@ -11,8 +11,9 @@ metrics: account adoption (`accounts.active`, `accounts.groups`), open-branch co (`branches.active`), a branch/temporal-correct managed-node count (`database.node_count.corenode`), and a new calendar-day-windowed `activity_24h` object (`logins`, `unique_logins`, `checks_started/passed/failed`, `artifacts_created/updated`, -`webhooks_fired_success`, `webhooks_fired_failure`). The check/artifact metrics are derived -from events that already flow today, harvested cheaply on the same windowed path. +`branches_created/merged/deleted`, `webhooks_fired_success`, `webhooks_fired_failure`). The +check/artifact/branch-lifecycle metrics are derived from events that already flow today, +harvested cheaply on the same windowed path. Technical approach: add the new fields to the existing Pydantic payload models; add gather functions that use the standard branch-safe `NodeManager.count` path for node-based metrics @@ -47,8 +48,8 @@ isolation: one failing source must not drop the payload. Event metrics must refl 24h window anchored to a deterministic calendar boundary (previous full UTC day), not to job-execution time, so daily snapshots tile with no overlap/gap despite the jittered cron. -**Scale/Scope**: ~13 new payload fields across 2 new sub-models + 2 extended sub-models; -~3 new gather functions; 1 windowed-event counter (reused for logins + 5 check/artifact +**Scale/Scope**: ~16 new payload fields across 2 new sub-models + 2 extended sub-models; +~3 new gather functions; 1 windowed-event counter (reused for logins + 8 check/artifact/branch metrics); 1 degradation helper; 1 constant bump. Producer-only. ## Constitution Check diff --git a/dev/specs/telemetry-collection-infp-589/research.md b/dev/specs/telemetry-collection-infp-589/research.md index 516a1c6d87e..0462eda1843 100644 --- a/dev/specs/telemetry-collection-infp-589/research.md +++ b/dev/specs/telemetry-collection-infp-589/research.md @@ -206,17 +206,20 @@ against `get_all_events()`: - **Pulled into Phase 1** (events emitted & counted today; raw per-period count *is* the depth-of-adoption signal): `validator.started/passed/failed` → `checks_*`; - `artifact.created/updated` → `artifacts_*`. Near-zero marginal cost, serves a stated Phase 1 - goal. -- **Held in Phase 2 although the events exist** (raw count is cheap but not the valuable - signal; a bare count would be permanent contract surface per FR-011 with no clear Phase 1 - use): + `artifact.created/updated` → `artifacts_*`; `branch.created/merged/deleted` → `branches_*`. + Near-zero marginal cost, serves a stated Phase 1 goal. Branch lifecycle counts need no + correlation — only branch *lifetime* (duration) does. +- **Held in Phase 2 although the events exist** (a bare count would be permanent contract + surface per FR-011 without clear standalone Phase 1 value): - PR governance — `proposed_change.*` exist, but "merged without review" needs per-PR review↔merge correlation. - - Branch lifetime — `branch.created/merged/deleted` exist, but time-to-merge needs durable - per-branch correlation. - - Node churn — `node.created/updated/deleted` exist, but raw churn counts lack standalone - Phase 1 value. + - Branch *lifetime* — the create→merge duration needs durable per-branch correlation (the + lifecycle *counts* are pulled in above; only the duration is deferred). + - Node churn — `node.created/updated/deleted` exist, but `node.updated` fires on every + attribute mutation incl. automated/computed writes, so the count is machine-dominated — a + noisy adoption proxy, not a clean signal. (Held on signal quality, not cost.) + - Branch `rebased`/`migrated` counts — maintenance/automation-driven, lower-signal than + create/merge/delete; deferred to keep the permanent field set focused. - **Stay Phase 2 — no events at all**: generators/transformations (no `generator.*`/`transform.*` events), distinct API tokens (no token identity in events), CLI/MCP/Sync (greenfield SDK instrumentation), licensing cores/RAM (product-scope decision). @@ -225,9 +228,10 @@ against `get_all_events()`: be cheap) while harvesting the genuine free wins the windowing enabler unlocks. The discipline matters because FR-011 makes every shipped field unremovable. -**Scope note (divergence from handoff PRD)**: checks/artifacts were not in the handoff PRD's -in-scope FR list; they are a deliberate, user-directed expansion recorded in -`alignment-check.md` §5. The events being verified-present is what makes the expansion safe. +**Scope note (divergence from handoff PRD)**: checks/artifacts and branch-lifecycle counts +were not in the handoff PRD's in-scope FR list; they are a deliberate, user-directed expansion +recorded in `alignment-check.md` §6. The events being verified-present is what makes the +expansion safe. ## Decision 8 — Webhook run terminality & count cost (secondary) diff --git a/dev/specs/telemetry-collection-infp-589/spec.md b/dev/specs/telemetry-collection-infp-589/spec.md index 6e5a6778e02..57b2f68001d 100644 --- a/dev/specs/telemetry-collection-infp-589/spec.md +++ b/dev/specs/telemetry-collection-infp-589/spec.md @@ -151,23 +151,27 @@ field is populated, and the payload is still sent/stored. --- -### User Story 5 - Depth-of-adoption activity: checks & artifacts (Priority: P2) +### User Story 5 - Depth-of-adoption activity: checks, artifacts & branch lifecycle (Priority: P2) As the OpsMill data team, I need the daily payload to report how many validation -checks ran (and their pass/fail outcomes) and how many artifacts were generated -over the same trailing-24h window, so I can measure *depth* of adoption — not just -that a deployment exists, but that its core workflows are actively used. +checks ran (and their pass/fail outcomes), how many artifacts were generated, and +how many branches were created / merged / deleted over the same trailing-24h +window, so I can measure *depth* of adoption — not just that a deployment exists, +but that its core workflows (validation, artifacts, and the branch-based change +workflow that is Infrahub's differentiator) are actively used. **Why this priority**: These ride entirely on the windowed event path delivered by User Story 1 — the underlying events (`validator.started/passed/failed`, -`artifact.created/updated`) are already emitted and counted today, so the marginal -cost is one additional event name per metric plus a parametrized test. They serve -the depth-of-adoption goal directly, making them a near-zero-cost extension of the -enabler rather than new scope of their own. +`artifact.created/updated`, `branch.created/merged/deleted`) are already emitted and +counted today, so the marginal cost is one additional event name per metric plus a +parametrized test. They serve the depth-of-adoption goal directly, making them a +near-zero-cost extension of the enabler rather than new scope of their own. Branch +*lifecycle counts* need no correlation (unlike branch *lifetime*, which is held to a +later phase). -**Independent Test**: Seed validator and artifact events inside and outside the -24h window, trigger the gather, and assert the check/artifact counts reflect -exactly the in-window events. +**Independent Test**: Seed validator, artifact, and branch events inside and outside +the 24h window, trigger the gather, and assert each count reflects exactly the +in-window events. **Acceptance Scenarios**: @@ -177,7 +181,10 @@ exactly the in-window events. 2. **Given** artifact events (`created`/`updated`) inside the window, **When** the payload is gathered, **Then** `activity_24h.artifacts_created` and `artifacts_updated` equal the in-window counts of each. -3. **Given** a deployment with no such events in the window, **When** the payload +3. **Given** branch events (`created`/`merged`/`deleted`) inside the window, **When** + the payload is gathered, **Then** `activity_24h.branches_created`, + `branches_merged`, and `branches_deleted` equal the in-window counts of each. +4. **Given** a deployment with no such events in the window, **When** the payload is gathered, **Then** each of these counts is `0` (not `null`, not absent), and on a source failure the affected field is `null`. @@ -248,6 +255,11 @@ exactly the in-window events. - **FR-013**: The payload MUST report `activity_24h.artifacts_created` and `activity_24h.artifacts_updated` as the windowed counts of `artifact.created` and `artifact.updated` events respectively, via the same windowed event path. [INFP-589, depth-of-adoption] +- **FR-014**: The payload MUST report `activity_24h.branches_created`, + `activity_24h.branches_merged`, and `activity_24h.branches_deleted` as the windowed + counts of `branch.created`, `branch.merged`, and `branch.deleted` events + respectively, via the same windowed event path. (Branch *lifetime* — create→merge + duration — remains out of scope; it needs per-branch correlation.) [INFP-589, depth-of-adoption] ### Governance Requirement @@ -270,6 +282,7 @@ exactly the in-window events. previous full UTC calendar day (a 24h window anchored to a fixed boundary, not to job-execution time): `logins`, `unique_logins`, `checks_started`, `checks_passed`, `checks_failed`, `artifacts_created`, `artifacts_updated`, + `branches_created`, `branches_merged`, `branches_deleted`, `webhooks_fired_success`, `webhooks_fired_failure`. - **Account**: A user account with a status (active vs. non-active) used for @@ -334,15 +347,20 @@ exactly the in-window events. CLI/MCP/Sync adoption, and GraphQL/REST metrics. (Checks and artifacts, formerly considered Phase 2, are pulled into Phase 1 — see FR-012/FR-013 — because their events already flow and serve a stated Phase 1 goal.) -- **Held in Phase 2 even though their events already flow** (aggregate counts would - be cheap, but the *valuable* signal needs correlation, not raw counts, so a bare - count would be permanent contract surface — FR-011 — without clear Phase 1 value): +- **Held in Phase 2 even though their events already flow** (a bare count would be + permanent contract surface — FR-011 — without clear standalone Phase 1 value): - **PR governance** — `proposed_change.*` events exist, but the useful metric ("merged without review") needs per-PR review↔merge correlation. - - **Branch lifetime** — `branch.created/merged/deleted` events exist, but the - useful metric (time-to-merge) needs durable per-branch correlation. - - **Node churn** — `node.created/updated/deleted` events exist, but raw 24h churn - counts have no clear standalone Phase 1 use. + - **Branch lifetime** — the create→merge *duration* metric needs durable per-branch + correlation. (Branch lifecycle *counts* — created/merged/deleted — are in scope as + `activity_24h.branches_*`, FR-014; only the duration is deferred.) + - **Node churn** — `node.created/updated/deleted` events exist, but `node.updated` + fires on every attribute mutation incl. automated/computed writes, so the count is + dominated by machine activity — a noisy proxy for human adoption, not a clean + standalone signal. + - **Branch `rebased` / `migrated` counts** — cheap, but rebase/migration are + maintenance/automation-driven and lower-signal than create/merge/delete; deferred + to keep the permanent field set focused. - Dashboard rendering and any consumer-side visualization (INFP-550 / SA-184). - Redefining or altering the existing `database.node_count.total` raw-vertex metric. diff --git a/dev/specs/telemetry-collection-infp-589/tasks.md b/dev/specs/telemetry-collection-infp-589/tasks.md index 8601ad46e49..819a4791852 100644 --- a/dev/specs/telemetry-collection-infp-589/tasks.md +++ b/dev/specs/telemetry-collection-infp-589/tasks.md @@ -53,7 +53,7 @@ full-payload resilience test that runs last, since it asserts every in-scope fie - [ ] T003 Bump `TELEMETRY_VERSION` in `backend/infrahub/telemetry/constants.py` from `"20250318"` to `"20260628"` (this also advances `DEFAULT_PAYLOAD_FORMAT`). - [ ] T004 In `backend/infrahub/telemetry/models.py`, add the additive payload models: - `TelemetryAccountData` with `active: int | None` and `groups: int | None`. - - `TelemetryActivity24hData` with `logins`, `unique_logins`, `checks_started`, `checks_passed`, `checks_failed`, `artifacts_created`, `artifacts_updated`, `webhooks_fired_success`, `webhooks_fired_failure`, all `int | None`. + - `TelemetryActivity24hData` with `logins`, `unique_logins`, `checks_started`, `checks_passed`, `checks_failed`, `artifacts_created`, `artifacts_updated`, `branches_created`, `branches_merged`, `branches_deleted`, `webhooks_fired_success`, `webhooks_fired_failure`, all `int | None`. - Extend `TelemetryBranchData` with `active: int | None = None` (keep `total: int`). - Widen `TelemetryDatabaseData.node_count` value type to `dict[str, int | None]`. - Add `accounts: TelemetryAccountData` and `activity_24h: TelemetryActivity24hData` to `TelemetryData` (always-present objects; per-field nullability). @@ -135,28 +135,30 @@ count, and assert `node_count["corenode"]` matches exactly (±0). --- -## Phase 6: User Story 5 — Depth-of-adoption: checks & artifacts (Priority: P2) +## Phase 6: User Story 5 — Depth-of-adoption: checks, artifacts & branch lifecycle (Priority: P2) -**Goal**: Emit `activity_24h.checks_started/passed/failed` and -`activity_24h.artifacts_created/updated` from events that already flow today, reusing the US1 -windowed event path unchanged. +**Goal**: Emit `activity_24h.checks_started/passed/failed`, +`activity_24h.artifacts_created/updated`, and +`activity_24h.branches_created/merged/deleted` from events that already flow today, reusing the +US1 windowed event path unchanged. > Rides entirely on US1's windowed counter (T009b/T010). Each metric is one more event name in > the same query — verified present via `get_all_events()`: `validator.started/passed/failed`, -> `artifact.created/updated`. +> `artifact.created/updated`, `branch.created/merged/deleted`. Branch *lifetime* (duration) is +> NOT included — it needs per-branch correlation (Phase 2). -**Independent Test**: Seed `validator.*` and `artifact.*` events in- and out-of-window; assert -each count reflects exactly the in-window events; assert genuine-empty → `0`. +**Independent Test**: Seed `validator.*`, `artifact.*`, and `branch.*` events in- and +out-of-window; assert each count reflects exactly the in-window events; assert genuine-empty → `0`. ### Tests for User Story 5 (write first, must fail) ⚠️ -- [ ] T022 [P] [US5] Component test in `backend/tests/component/telemetry/test_task_manager.py` (parametrized off the US1 windowing fixture): seed `validator.started/passed/failed` and `artifact.created/updated` events in- and out-of-window; assert `checks_started/passed/failed` and `artifacts_created/updated` equal the in-window counts and that out-of-window events are excluded. +- [ ] T022 [P] [US5] Component test in `backend/tests/component/telemetry/test_task_manager.py` (parametrized off the US1 windowing fixture): seed `validator.started/passed/failed`, `artifact.created/updated`, and `branch.created/merged/deleted` events in- and out-of-window; assert `checks_*`, `artifacts_*`, and `branches_*` equal the in-window counts and that out-of-window events are excluded. ### Implementation for User Story 5 -- [ ] T023 [US5] Extend the windowed event counter (T010) to also count `validator.started`, `validator.passed`, `validator.failed`, `artifact.created`, `artifact.updated`, and extend `gather_activity_24h` (T013) to populate the five new fields, each through the degradation helper (per-field null isolation). No change to `gather_prefect_events`. +- [ ] T023 [US5] Extend the windowed event counter (T010) to also count `validator.started`, `validator.passed`, `validator.failed`, `artifact.created`, `artifact.updated`, `branch.created`, `branch.merged`, `branch.deleted`, and extend `gather_activity_24h` (T013) to populate the eight new fields, each through the degradation helper (per-field null isolation). No change to `gather_prefect_events`. -**Checkpoint**: Depth-of-adoption check/artifact metrics present and windowed. +**Checkpoint**: Depth-of-adoption check/artifact/branch metrics present and windowed. --- @@ -174,7 +176,7 @@ others populated, and the payload is still built and stored. Assert genuine-empt ### Tests for User Story 4 (write first, must fail) ⚠️ -- [ ] T024 [US4] Component test in `backend/tests/component/telemetry/test_tasks.py`: run `gather_anonymous_telemetry_data` on a healthy stack and assert presence of `accounts.{active,groups}`, `branches.active`, `database.node_count.corenode`, and all `activity_24h` fields (`logins`, `unique_logins`, `checks_started/passed/failed`, `artifacts_created/updated`, `webhooks_fired_success/failure`). +- [ ] T024 [US4] Component test in `backend/tests/component/telemetry/test_tasks.py`: run `gather_anonymous_telemetry_data` on a healthy stack and assert presence of `accounts.{active,groups}`, `branches.active`, `database.node_count.corenode`, and all `activity_24h` fields (`logins`, `unique_logins`, `checks_started/passed/failed`, `artifacts_created/updated`, `branches_created/merged/deleted`, `webhooks_fired_success/failure`). - [ ] T025 [US4] Resilience test in `backend/tests/component/telemetry/test_tasks.py`: make one source fail via an injected failing collaborator/fixture (no mock); assert that field is `null`, every other field is populated, and the snapshot is still stored. Add a genuine-empty case asserting `0`, not `null`. ### Implementation for User Story 4 @@ -261,9 +263,11 @@ Task: "Regression test gather_prefect_events unchanged" - `[Story]` label maps each task to a user story for traceability (this file only — never in source). - Verify each test fails before implementing. - US4's value is P1, but its full-payload assertion depends on US1–US3 + US5, so it is scheduled last. -- US5 (checks/artifacts) is a user-directed pull-in from Phase 2 — cheap because the events - already flow; see `alignment-check.md` §5 for the sanctioned-scope-expansion record. -- Out of scope (do not implement): `database.node_count.user` (blocked); PR/branch/node-churn - aggregate counts (events exist but value needs correlation — held in Phase 2); remaining - Phase 2 metrics (generators/transforms, tokens, CLI/MCP/Sync, licensing); dashboards; - redefining `node_count.total`; persisting logins in Neo4j. +- US5 (checks/artifacts/branch-lifecycle counts) is a user-directed pull-in from Phase 2 — + cheap because the events already flow; see `alignment-check.md` §6 for the + sanctioned-scope-expansion record. +- Out of scope (do not implement): `database.node_count.user` (blocked); branch *lifetime* + (duration — needs correlation); PR "merged-without-review" (needs correlation); node churn + (`node.*` — machine-dominated, noisy); branch `rebased`/`migrated` counts (low-signal); + remaining Phase 2 metrics (generators/transforms, tokens, CLI/MCP/Sync, licensing); + dashboards; redefining `node_count.total`; persisting logins in Neo4j. From 4b5fdf8d063f3452780f02c2b7ae221b2761e54d Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Sun, 28 Jun 2026 21:55:53 +0200 Subject: [PATCH 09/75] docs(telemetry): INFP-589 clarify checks denominator + webhook outcomes-only asymmetry Keep checks_started as the rate denominator (pass/failure/incomplete rates); document that the three checks_* fields are non-additive. Record that webhooks are intentionally outcomes-only this phase (no attempted denominator), an additive-later choice, so the checks/webhooks asymmetry is deliberate. Co-Authored-By: Claude Opus 4.8 --- .../contracts/telemetry-payload.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md b/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md index f41e7ec0f80..a297a607bae 100644 --- a/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md +++ b/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md @@ -85,6 +85,17 @@ deterministic boundary (not gather-time `now`) so daily snapshots tile exactly. | `activity_24h.webhooks_fired_success` | Prefect `webhook-process` flow runs, `COMPLETED` | prev. UTC day | `0` | `null` | | `activity_24h.webhooks_fired_failure` | Prefect `webhook-process` flow runs, `FAILED`/`CRASHED`/`TIMEDOUT` | prev. UTC day | `0` | `null` | +**Interpretation notes (checks vs webhooks).** + +- The three `checks_*` fields are **not additive**: `checks_started` is the *denominator* + (validation runs initiated in-window), while `checks_passed`/`checks_failed` are terminal + outcomes. Consumers derive pass rate (`passed/started`), failure rate (`failed/started`), and + incomplete/crash rate (`1 − (passed+failed)/started`). Do **not** sum all three. +- `webhooks_*` is intentionally **outcomes-only** this phase (no `webhooks_attempted` + denominator), so only absolute success/failure counts are available — not a webhook failure + *rate*. This asymmetry with `checks_*` is deliberate; an attempted/started count can be added + later additively if rate analysis is needed, without breaking the contract. + **Node-count metrics (FR-009).** `node_count` carries three semantically distinct, strictly nesting keys — `user ⊆ corenode ⊆ total`: From 11f596e871645d22a6f4f6a56e5f722a8ddb25f9 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Sun, 28 Jun 2026 22:13:32 +0200 Subject: [PATCH 10/75] spec(telemetry): INFP-589 operational FR-009 node-metric defs + Key Entities entry Reframe FR-009 definitions operationally (by how each is computed): total = raw vertices; corenode = CoreNode-generic via branch-safe NodeManager.count (every schema-managed node whose namespace is not Schema/Internal, incl. CoreAccount); user = future/blocked customer-facing subset with an open Builtin boundary that must not be assumed. Add a node-count-metrics entry to Key Entities so the entity list matches. Co-Authored-By: Claude Opus 4.8 --- .../telemetry-collection-infp-589/spec.md | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/dev/specs/telemetry-collection-infp-589/spec.md b/dev/specs/telemetry-collection-infp-589/spec.md index 57b2f68001d..7a27437f577 100644 --- a/dev/specs/telemetry-collection-infp-589/spec.md +++ b/dev/specs/telemetry-collection-infp-589/spec.md @@ -238,11 +238,22 @@ in-window events. accounts over the same window). [IFC-2823] - **FR-009**: The feature MUST NOT change the existing raw-vertex `database.node_count.total` field, and MUST document the distinction between the - three node metrics at the namespace level so they cannot later become synonyms: - `total` (raw vertices), `corenode` (all managed nodes — the `Core` + `Builtin` + - user-defined namespaces, this phase), and the future/blocked `user` (the - customer-facing subset that excludes the `Core` management namespace). They nest - strictly: `user` ⊆ `corenode` ⊆ `total`. [IFC-2821] + node metrics so they cannot later become synonyms. Definitions are operational, by + how each is computed: + - `total` — raw graph vertex count (includes history, all branches, and internal + bookkeeping nodes). Unchanged by this feature. + - `corenode` — count of nodes carrying the `CoreNode` generic label, obtained via + the branch-safe `NodeManager.count` path (same as FR-003). By construction this + is every schema-managed node whose namespace is not internal-only + (`Schema`/`Internal`); it therefore includes management kinds such as + `CoreAccount`, not just user-defined data. + - `user` (future / blocked — IFC-2825, not in this feature) — a narrower, + customer-facing subset of `corenode`. Its exact namespace boundary — in + particular whether the `Builtin` namespace (tags, IPAM addresses/prefixes) counts + as user data — is an open product decision and MUST NOT be assumed here. + + The metrics nest by construction (`user ⊆ corenode ⊆ total`), but only `total` and + `corenode` are defined and delivered in this phase. [IFC-2821] - **FR-010**: When a metric's source fails, that field MUST be set to `null` while the rest of the payload is still emitted and stored; when a source succeeds with nothing to count, the field MUST be `0`, not `null`. [IFC-2820] @@ -285,6 +296,11 @@ in-window events. `branches_created`, `branches_merged`, `branches_deleted`, `webhooks_fired_success`, `webhooks_fired_failure`. +- **Node-count metrics (`database.node_count`)**: A map of node counts. `total` = + raw vertices (existing, unchanged); `corenode` = `CoreNode`-generic count via the + branch-safe count path (new, this phase). A future `user` subset is blocked + (IFC-2825) and not added here. The map may carry a `null` only on the new + `corenode` key (per FR-010 / GR-001). - **Account**: A user account with a status (active vs. non-active) used for `accounts.active`. - **Account group**: A grouping of accounts, counted for `accounts.groups`. From 62b3c05de6917e69d03029b0c8d4ad38f25b2369 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Sun, 28 Jun 2026 23:31:42 +0200 Subject: [PATCH 11/75] chore(telemetry): add changelog fragment and test skeletons for phase 1 Add a Towncrier 'added' fragment describing the new additive telemetry payload fields (accounts, branches.active, node_count.corenode, the activity_24h object) and the payload_format bump. Create empty, collectible pytest skeletons for the upcoming degradation helper unit test and the gather-flow component test, mirroring the telemetry source layout. Co-Authored-By: Claude Opus 4.8 --- backend/tests/component/telemetry/test_tasks.py | 6 ++++++ backend/tests/unit/telemetry/test_degradation.py | 6 ++++++ changelog/+telemetry-phase1.added.md | 5 +++++ dev/specs/telemetry-collection-infp-589/tasks.md | 4 ++-- 4 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 backend/tests/component/telemetry/test_tasks.py create mode 100644 backend/tests/unit/telemetry/test_degradation.py create mode 100644 changelog/+telemetry-phase1.added.md diff --git a/backend/tests/component/telemetry/test_tasks.py b/backend/tests/component/telemetry/test_tasks.py new file mode 100644 index 00000000000..9fe93ee20db --- /dev/null +++ b/backend/tests/component/telemetry/test_tasks.py @@ -0,0 +1,6 @@ +"""Component tests for the telemetry gather flow and payload resilience. + +Skeleton placeholder: assertions are added alongside the gather-flow wiring. +""" + +from __future__ import annotations diff --git a/backend/tests/unit/telemetry/test_degradation.py b/backend/tests/unit/telemetry/test_degradation.py new file mode 100644 index 00000000000..d2fe9076178 --- /dev/null +++ b/backend/tests/unit/telemetry/test_degradation.py @@ -0,0 +1,6 @@ +"""Unit tests for the telemetry per-metric graceful-degradation helper. + +Skeleton placeholder: assertions are added alongside the degradation helper. +""" + +from __future__ import annotations diff --git a/changelog/+telemetry-phase1.added.md b/changelog/+telemetry-phase1.added.md new file mode 100644 index 00000000000..9e5f3afa214 --- /dev/null +++ b/changelog/+telemetry-phase1.added.md @@ -0,0 +1,5 @@ +The daily anonymous telemetry payload now reports additional adoption and activity signals. New fields cover active accounts and account groups (`accounts.active`, `accounts.groups`), the count of open non-system branches (`branches.active`), and a branch- and temporal-correct managed-node count (`database.node_count.corenode`) computed the same way the product counts nodes, distinct from the existing raw vertex total. + +A new `activity_24h` object summarizes what happened over the previous full UTC calendar day: logins and unique logins, validation checks started/passed/failed, artifacts created/updated, branches created/merged/deleted, and webhook deliveries that succeeded or failed. + +All changes are additive and backwards-compatible. Each field is reported independently, so a single failing source yields `null` for that field while the rest of the payload is still gathered and sent; a source that succeeds with nothing to count reports `0`. The `payload_format` identifier is advanced to reflect the new payload version. diff --git a/dev/specs/telemetry-collection-infp-589/tasks.md b/dev/specs/telemetry-collection-infp-589/tasks.md index 819a4791852..1200c53a6ca 100644 --- a/dev/specs/telemetry-collection-infp-589/tasks.md +++ b/dev/specs/telemetry-collection-infp-589/tasks.md @@ -39,8 +39,8 @@ full-payload resilience test that runs last, since it asserts every in-scope fie **Purpose**: Pre-flight; the telemetry module already exists, so setup is minimal. -- [ ] T001 [P] Add a Towncrier changelog fragment under `changelog/` (e.g. `+telemetry-phase1.added.md`) describing the new additive telemetry fields and the `payload_format` bump. -- [ ] T002 [P] Confirm the telemetry test layout exists and create empty skeletons where missing: `backend/tests/unit/telemetry/test_degradation.py`, `backend/tests/component/telemetry/test_tasks.py` (mirror source structure; no assertions yet). +- [X] T001 [P] Add a Towncrier changelog fragment under `changelog/` (e.g. `+telemetry-phase1.added.md`) describing the new additive telemetry fields and the `payload_format` bump. +- [X] T002 [P] Confirm the telemetry test layout exists and create empty skeletons where missing: `backend/tests/unit/telemetry/test_degradation.py`, `backend/tests/component/telemetry/test_tasks.py` (mirror source structure; no assertions yet). --- From d44bc930b804fcd210779f30009bc0eae7de042e Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Sun, 28 Jun 2026 23:36:44 +0200 Subject: [PATCH 12/75] feat(telemetry): additive payload models, version bump, degradation helper Bump TELEMETRY_VERSION to 20260628 (advancing DEFAULT_PAYLOAD_FORMAT). Add additive payload models: TelemetryAccountData, TelemetryActivity24hData, extend TelemetryBranchData with active, widen TelemetryDatabaseData.node_count values to allow null on a per-key basis, and add accounts/activity_24h to TelemetryData as always-present objects with per-field nullability. Add safe_metric, an async per-metric graceful-degradation helper that returns a coroutine's result or null on failure (logged), so one failing source nulls only its own field instead of dropping the whole payload. Covered by a no-mock unit test using plain coroutines as doubles. Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/telemetry/constants.py | 2 +- backend/infrahub/telemetry/models.py | 25 ++++++++++- backend/infrahub/telemetry/tasks.py | 45 ++++++++++++++++++- .../tests/unit/telemetry/test_degradation.py | 33 +++++++++++++- .../telemetry-collection-infp-589/tasks.md | 8 ++-- 5 files changed, 105 insertions(+), 8 deletions(-) diff --git a/backend/infrahub/telemetry/constants.py b/backend/infrahub/telemetry/constants.py index 4b9980b96bb..fa81e3c9c49 100644 --- a/backend/infrahub/telemetry/constants.py +++ b/backend/infrahub/telemetry/constants.py @@ -1,7 +1,7 @@ from enum import StrEnum TELEMETRY_KIND: str = "community" -TELEMETRY_VERSION: str = "20250318" +TELEMETRY_VERSION: str = "20260628" class RemoteSendStatus(StrEnum): diff --git a/backend/infrahub/telemetry/models.py b/backend/infrahub/telemetry/models.py index 1c2d78866e6..0f47151b3ff 100644 --- a/backend/infrahub/telemetry/models.py +++ b/backend/infrahub/telemetry/models.py @@ -10,6 +10,27 @@ class TelemetryWorkerData(BaseModel): class TelemetryBranchData(BaseModel): total: int + active: int | None = None + + +class TelemetryAccountData(BaseModel): + active: int | None + groups: int | None + + +class TelemetryActivity24hData(BaseModel): + logins: int | None + unique_logins: int | None + checks_started: int | None + checks_passed: int | None + checks_failed: int | None + artifacts_created: int | None + artifacts_updated: int | None + branches_created: int | None + branches_merged: int | None + branches_deleted: int | None + webhooks_fired_success: int | None + webhooks_fired_failure: int | None class TelemetrySchemaData(BaseModel): @@ -32,7 +53,7 @@ class TelemetryDatabaseSystemInfoData(BaseModel): class TelemetryDatabaseData(BaseModel): database_type: str relationship_count: dict[str, int] - node_count: dict[str, int] + node_count: dict[str, int | None] servers: list[TelemetryDatabaseServerData] system_info: TelemetryDatabaseSystemInfoData | None @@ -59,6 +80,8 @@ class TelemetryData(BaseModel): platform: str workers: TelemetryWorkerData branches: TelemetryBranchData + accounts: TelemetryAccountData + activity_24h: TelemetryActivity24hData features: dict[str, int] schema_info: TelemetrySchemaData database: TelemetryDatabaseData diff --git a/backend/infrahub/telemetry/tasks.py b/backend/infrahub/telemetry/tasks.py index 216af63f6c3..dad3eca0603 100644 --- a/backend/infrahub/telemetry/tasks.py +++ b/backend/infrahub/telemetry/tasks.py @@ -1,7 +1,9 @@ import hashlib import json +import logging import platform import time +from collections.abc import Awaitable from typing import Any from prefect import flow, task @@ -20,12 +22,36 @@ RemoteSendStatus, ) from .database import gather_database_information -from .models import TelemetryBranchData, TelemetryData, TelemetrySchemaData, TelemetryWorkerData +from .models import ( + TelemetryAccountData, + TelemetryActivity24hData, + TelemetryBranchData, + TelemetryData, + TelemetrySchemaData, + TelemetryWorkerData, +) from .repository import TelemetrySnapshotRepository from .snapshot import TelemetrySnapshot from .task_manager import gather_prefect_information from .utils import determine_infrahub_type +log = logging.getLogger(__name__) + + +async def safe_metric[T](coro: Awaitable[T]) -> T | None: + """Run one metric coroutine in isolation, degrading a failure to ``None``. + + Returns the awaited result on success — including a falsy value such as + ``0`` (a source that succeeded with nothing to count). On any exception the + failure is logged and ``None`` is returned, so a single broken source nulls + only its own field instead of dropping the whole payload. + """ + try: + return await coro + except Exception as exc: + log.warning("Telemetry metric collection failed; reporting null for this field: %s", exc) + return None + @task(name="telemetry-schema-information", task_run_name="Gather Schema Information", cache_policy=NONE) async def gather_schema_information(branch: Branch) -> TelemetrySchemaData: @@ -81,6 +107,23 @@ async def gather_anonymous_telemetry_data() -> TelemetryData: branches=TelemetryBranchData( total=len(registry.branch), ), + # Always-present objects; individual fields are populated by their gather + # functions and default to null until each metric is wired in. + accounts=TelemetryAccountData(active=None, groups=None), + activity_24h=TelemetryActivity24hData( + logins=None, + unique_logins=None, + checks_started=None, + checks_passed=None, + checks_failed=None, + artifacts_created=None, + artifacts_updated=None, + branches_created=None, + branches_merged=None, + branches_deleted=None, + webhooks_fired_success=None, + webhooks_fired_failure=None, + ), features=await gather_feature_information(), schema_info=await gather_schema_information(branch=default_branch), database=await gather_database_information(db=await get_database()), diff --git a/backend/tests/unit/telemetry/test_degradation.py b/backend/tests/unit/telemetry/test_degradation.py index d2fe9076178..a77e3ce6a2e 100644 --- a/backend/tests/unit/telemetry/test_degradation.py +++ b/backend/tests/unit/telemetry/test_degradation.py @@ -1,6 +1,37 @@ """Unit tests for the telemetry per-metric graceful-degradation helper. -Skeleton placeholder: assertions are added alongside the degradation helper. +The helper runs a single metric coroutine and isolates its failure: a raising +coroutine degrades to ``None`` (interpreted downstream as "source failed"), +while a coroutine that completes returns its value untouched — including a +falsy ``0`` (interpreted as "source succeeded with nothing to count"). """ from __future__ import annotations + +from infrahub.telemetry.tasks import safe_metric + + +async def _raises() -> int: + raise RuntimeError("metric source unavailable") + + +async def _returns_zero() -> int: + return 0 + + +async def _returns_value() -> int: + return 42 + + +async def test_raising_coroutine_degrades_to_none() -> None: + assert await safe_metric(_raises()) is None + + +async def test_zero_result_is_preserved() -> None: + result = await safe_metric(_returns_zero()) + assert result == 0 + assert result is not None + + +async def test_non_zero_result_is_preserved() -> None: + assert await safe_metric(_returns_value()) == 42 diff --git a/dev/specs/telemetry-collection-infp-589/tasks.md b/dev/specs/telemetry-collection-infp-589/tasks.md index 1200c53a6ca..aea1b834845 100644 --- a/dev/specs/telemetry-collection-infp-589/tasks.md +++ b/dev/specs/telemetry-collection-infp-589/tasks.md @@ -50,15 +50,15 @@ full-payload resilience test that runs last, since it asserts every in-scope fie **⚠️ CRITICAL**: No user story metric can be wired until this phase is complete. -- [ ] T003 Bump `TELEMETRY_VERSION` in `backend/infrahub/telemetry/constants.py` from `"20250318"` to `"20260628"` (this also advances `DEFAULT_PAYLOAD_FORMAT`). -- [ ] T004 In `backend/infrahub/telemetry/models.py`, add the additive payload models: +- [X] T003 Bump `TELEMETRY_VERSION` in `backend/infrahub/telemetry/constants.py` from `"20250318"` to `"20260628"` (this also advances `DEFAULT_PAYLOAD_FORMAT`). +- [X] T004 In `backend/infrahub/telemetry/models.py`, add the additive payload models: - `TelemetryAccountData` with `active: int | None` and `groups: int | None`. - `TelemetryActivity24hData` with `logins`, `unique_logins`, `checks_started`, `checks_passed`, `checks_failed`, `artifacts_created`, `artifacts_updated`, `branches_created`, `branches_merged`, `branches_deleted`, `webhooks_fired_success`, `webhooks_fired_failure`, all `int | None`. - Extend `TelemetryBranchData` with `active: int | None = None` (keep `total: int`). - Widen `TelemetryDatabaseData.node_count` value type to `dict[str, int | None]`. - Add `accounts: TelemetryAccountData` and `activity_24h: TelemetryActivity24hData` to `TelemetryData` (always-present objects; per-field nullability). -- [ ] T005 [P] Write the degradation-helper unit test in `backend/tests/unit/telemetry/test_degradation.py` (TDD — must fail first): a coroutine that raises → helper returns `None`; a coroutine returning `0` → `0`; a coroutine returning `N` → `N`. No DB, no mock (plain coroutines as doubles). -- [ ] T006 Implement the async graceful-degradation helper in `backend/infrahub/telemetry/tasks.py`: runs a metric coroutine, returns its result, and on any exception logs a warning and returns `None`. Make T005 pass. +- [X] T005 [P] Write the degradation-helper unit test in `backend/tests/unit/telemetry/test_degradation.py` (TDD — must fail first): a coroutine that raises → helper returns `None`; a coroutine returning `0` → `0`; a coroutine returning `N` → `N`. No DB, no mock (plain coroutines as doubles). +- [X] T006 Implement the async graceful-degradation helper in `backend/infrahub/telemetry/tasks.py`: runs a metric coroutine, returns its result, and on any exception logs a warning and returns `None`. Make T005 pass. **Checkpoint**: Payload models, version bump, and degradation helper ready. Stories can begin. From a2fd5fc392ce40d494e13d43978fe7fee51d554b Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Sun, 28 Jun 2026 23:55:05 +0200 Subject: [PATCH 13/75] feat(telemetry): add windowed activity_24h logins + webhook metrics Add a deterministic 24h window (previous full UTC calendar day, anchored to midnight) and a new windowed Prefect event path feeding activity_24h: - window.py: floor_to_midnight_utc + get_activity_window returning [window_start, window_end) so consecutive daily runs tile exactly. - task_manager.py: count_windowed_event (/events/count-by/event with an occurred window), count_windowed_unique_resources (/events/count-by/resource bucket count = unique logins), and count_webhook_runs (webhook-process flow runs split COMPLETED vs FAILED/CRASHED; non-terminal counted in neither). gather_activity_24h assembles logins/unique_logins/webhook success+failure via per-field degradation; check/artifact/branch fields stay null for a later chunk. - tasks.py: wire activity_24h into gather_anonymous_telemetry_data via the Prefect client. The existing unwindowed gather_prefect_events is untouched. - Move safe_metric to utils.py (re-exported from tasks.py) to avoid an import cycle now that task_manager.py needs per-field degradation. Component tests seed login events with explicit occurred timestamps and webhook flow runs around the window boundary, asserting in-window-only counts, distinct-account collapsing, terminal-state splitting, and that the existing event tally is unchanged. Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/telemetry/task_manager.py | 140 ++++++++++- backend/infrahub/telemetry/tasks.py | 40 +--- backend/infrahub/telemetry/utils.py | 19 ++ backend/infrahub/telemetry/window.py | 23 ++ .../component/telemetry/test_task_manager.py | 217 +++++++++++++++++- .../telemetry-collection-infp-589/tasks.md | 18 +- 6 files changed, 411 insertions(+), 46 deletions(-) create mode 100644 backend/infrahub/telemetry/window.py diff --git a/backend/infrahub/telemetry/task_manager.py b/backend/infrahub/telemetry/task_manager.py index 7e12b47d37f..72b79d55fc7 100644 --- a/backend/infrahub/telemetry/task_manager.py +++ b/backend/infrahub/telemetry/task_manager.py @@ -1,16 +1,32 @@ +from datetime import datetime from typing import Any from prefect import task from prefect.cache_policies import NONE from prefect.client.orchestration import PrefectClient, get_client -from prefect.client.schemas.objects import WorkerStatus - +from prefect.client.schemas.filters import ( + FlowFilter, + FlowFilterName, + FlowRunFilter, + FlowRunFilterStartTime, + FlowRunFilterState, + FlowRunFilterStateType, +) +from prefect.client.schemas.objects import StateType, WorkerStatus +from prefect.types import DateTime + +from infrahub.events.account_action import AccountLoggedInEvent from infrahub.events.utils import get_all_events from infrahub.trigger.constants import NAME_SEPARATOR from infrahub.trigger.models import TriggerType from infrahub.trigger.setup import gather_all_automations -from .models import TelemetryPrefectData, TelemetryWorkPoolData +from .models import TelemetryActivity24hData, TelemetryPrefectData, TelemetryWorkPoolData +from .utils import safe_metric +from .window import get_activity_window + +WEBHOOK_FLOW_NAME = "webhook-process" +WEBHOOK_FAILURE_STATES = [StateType.FAILED, StateType.CRASHED] @task(name="telemetry-gather-work-pools", task_run_name="Gather Work Pools", cache_policy=NONE) @@ -52,6 +68,124 @@ async def count_events(event_name: str) -> int: return events +def _windowed_event_filter(event_name: str, window_start: datetime, window_end: datetime) -> dict[str, Any]: + """Build the count-by request body: an event-name filter narrowed to ``[start, end)``.""" + return { + "filter": { + "event": {"name": [event_name]}, + "occurred": {"since": window_start.isoformat(), "until": window_end.isoformat()}, + } + } + + +@task(name="telemetry-gather-windowed-event", task_run_name="Gather Windowed Event Count", cache_policy=NONE) +async def count_windowed_event( + client: PrefectClient, event_name: str, window_start: datetime, window_end: datetime +) -> int: + """Count events of one name that occurred within ``[window_start, window_end)``.""" + payload = _windowed_event_filter(event_name=event_name, window_start=window_start, window_end=window_end) + response = await client._client.post("/events/count-by/event", json=payload) + response.raise_for_status() + data = response.json() + if not isinstance(data, list): + return 0 + return sum(bucket["count"] for bucket in data) + + +@task(name="telemetry-gather-windowed-unique", task_run_name="Gather Windowed Unique Count", cache_policy=NONE) +async def count_windowed_unique_resources( + client: PrefectClient, event_name: str, window_start: datetime, window_end: datetime +) -> int: + """Count distinct resources emitting one event name within ``[window_start, window_end)``. + + Counting by resource returns one bucket per distinct ``prefect.resource.id``, so the number + of buckets is the distinct-resource total over the window. + """ + payload = _windowed_event_filter(event_name=event_name, window_start=window_start, window_end=window_end) + response = await client._client.post("/events/count-by/resource", json=payload) + response.raise_for_status() + data = response.json() + if not isinstance(data, list): + return 0 + return len(data) + + +@task(name="telemetry-gather-webhook-runs", task_run_name="Gather Webhook Runs", cache_policy=NONE) +async def count_webhook_runs(client: PrefectClient, window_start: datetime, window_end: datetime) -> tuple[int, int]: + """Return ``(success, failure)`` webhook flow-run counts started within the window. + + Success is the count of runs in a terminal ``COMPLETED`` state; failure is the count in a + terminal ``FAILED``/``CRASHED`` state. Runs still in a non-terminal state at gather time are + counted in neither. + """ + flow_filter = FlowFilter(name=FlowFilterName(any_=[WEBHOOK_FLOW_NAME])) + # The flow-run start-time filter is typed with Prefect's own datetime; coerce the stdlib + # boundaries to it so the comparison stays well-typed. + after = DateTime.fromisoformat(window_start.isoformat()) + before = DateTime.fromisoformat(window_end.isoformat()) + + def runs_in_states(states: list[StateType]) -> FlowRunFilter: + return FlowRunFilter( + start_time=FlowRunFilterStartTime(after_=after, before_=before), + state=FlowRunFilterState(type=FlowRunFilterStateType(any_=states)), + ) + + success_runs = await client.read_flow_runs( + flow_filter=flow_filter, flow_run_filter=runs_in_states([StateType.COMPLETED]) + ) + failure_runs = await client.read_flow_runs( + flow_filter=flow_filter, flow_run_filter=runs_in_states(WEBHOOK_FAILURE_STATES) + ) + return len(success_runs), len(failure_runs) + + +@task(name="telemetry-gather-activity-24h", task_run_name="Gather 24h Activity", cache_policy=NONE) +async def gather_activity_24h(client: PrefectClient) -> TelemetryActivity24hData: + """Assemble the 24h activity metrics over the previous full UTC calendar day. + + Each source is isolated so one failing source nulls only its own field. The + check/artifact/branch fields are populated by a later extension and stay null here. + """ + window_start, window_end = get_activity_window() + + logins = await safe_metric( + count_windowed_event.fn( + client=client, + event_name=AccountLoggedInEvent.event_name, + window_start=window_start, + window_end=window_end, + ) + ) + unique_logins = await safe_metric( + count_windowed_unique_resources.fn( + client=client, + event_name=AccountLoggedInEvent.event_name, + window_start=window_start, + window_end=window_end, + ) + ) + webhook_counts = await safe_metric( + count_webhook_runs.fn(client=client, window_start=window_start, window_end=window_end) + ) + webhooks_fired_success = webhook_counts[0] if webhook_counts is not None else None + webhooks_fired_failure = webhook_counts[1] if webhook_counts is not None else None + + return TelemetryActivity24hData( + logins=logins, + unique_logins=unique_logins, + checks_started=None, + checks_passed=None, + checks_failed=None, + artifacts_created=None, + artifacts_updated=None, + branches_created=None, + branches_merged=None, + branches_deleted=None, + webhooks_fired_success=webhooks_fired_success, + webhooks_fired_failure=webhooks_fired_failure, + ) + + @task(name="telemetry-gather-automations", task_run_name="Gather Automations", cache_policy=NONE) async def gather_prefect_automations(client: PrefectClient) -> dict[str, Any]: automations = await gather_all_automations(client=client) diff --git a/backend/infrahub/telemetry/tasks.py b/backend/infrahub/telemetry/tasks.py index dad3eca0603..211736b7a13 100644 --- a/backend/infrahub/telemetry/tasks.py +++ b/backend/infrahub/telemetry/tasks.py @@ -3,11 +3,11 @@ import logging import platform import time -from collections.abc import Awaitable from typing import Any from prefect import flow, task from prefect.cache_policies import NONE +from prefect.client.orchestration import get_client as get_prefect_client from prefect.logging import get_run_logger from infrahub import __version__, config @@ -24,7 +24,6 @@ from .database import gather_database_information from .models import ( TelemetryAccountData, - TelemetryActivity24hData, TelemetryBranchData, TelemetryData, TelemetrySchemaData, @@ -32,25 +31,12 @@ ) from .repository import TelemetrySnapshotRepository from .snapshot import TelemetrySnapshot -from .task_manager import gather_prefect_information -from .utils import determine_infrahub_type +from .task_manager import gather_activity_24h, gather_prefect_information +from .utils import determine_infrahub_type, safe_metric log = logging.getLogger(__name__) - -async def safe_metric[T](coro: Awaitable[T]) -> T | None: - """Run one metric coroutine in isolation, degrading a failure to ``None``. - - Returns the awaited result on success — including a falsy value such as - ``0`` (a source that succeeded with nothing to count). On any exception the - failure is logged and ``None`` is returned, so a single broken source nulls - only its own field instead of dropping the whole payload. - """ - try: - return await coro - except Exception as exc: - log.warning("Telemetry metric collection failed; reporting null for this field: %s", exc) - return None +__all__ = ["safe_metric"] @task(name="telemetry-schema-information", task_run_name="Gather Schema Information", cache_policy=NONE) @@ -93,6 +79,9 @@ async def gather_anonymous_telemetry_data() -> TelemetryData: component = await get_component() workers = await component.list_workers(branch=default_branch.name, schema_hash=False) + async with get_prefect_client(sync_client=False) as prefect_client: + activity_24h = await gather_activity_24h(client=prefect_client) + data = TelemetryData( deployment_id=registry.id, execution_time=None, @@ -110,20 +99,7 @@ async def gather_anonymous_telemetry_data() -> TelemetryData: # Always-present objects; individual fields are populated by their gather # functions and default to null until each metric is wired in. accounts=TelemetryAccountData(active=None, groups=None), - activity_24h=TelemetryActivity24hData( - logins=None, - unique_logins=None, - checks_started=None, - checks_passed=None, - checks_failed=None, - artifacts_created=None, - artifacts_updated=None, - branches_created=None, - branches_merged=None, - branches_deleted=None, - webhooks_fired_success=None, - webhooks_fired_failure=None, - ), + activity_24h=activity_24h, features=await gather_feature_information(), schema_info=await gather_schema_information(branch=default_branch), database=await gather_database_information(db=await get_database()), diff --git a/backend/infrahub/telemetry/utils.py b/backend/infrahub/telemetry/utils.py index dac6385b2ec..cf1ff766830 100644 --- a/backend/infrahub/telemetry/utils.py +++ b/backend/infrahub/telemetry/utils.py @@ -1,7 +1,11 @@ import importlib.metadata +import logging +from collections.abc import Awaitable from .constants import InfrahubType +log = logging.getLogger(__name__) + def determine_infrahub_type() -> InfrahubType: try: @@ -9,3 +13,18 @@ def determine_infrahub_type() -> InfrahubType: return InfrahubType.ENTERPRISE except importlib.metadata.PackageNotFoundError: return InfrahubType.COMMUNITY + + +async def safe_metric[T](coro: Awaitable[T]) -> T | None: + """Run one metric coroutine in isolation, degrading a failure to ``None``. + + Returns the awaited result on success — including a falsy value such as + ``0`` (a source that succeeded with nothing to count). On any exception the + failure is logged and ``None`` is returned, so a single broken source nulls + only its own field instead of dropping the whole payload. + """ + try: + return await coro + except Exception as exc: + log.warning("Telemetry metric collection failed; reporting null for this field: %s", exc) + return None diff --git a/backend/infrahub/telemetry/window.py b/backend/infrahub/telemetry/window.py new file mode 100644 index 00000000000..e634acf6d8d --- /dev/null +++ b/backend/infrahub/telemetry/window.py @@ -0,0 +1,23 @@ +from datetime import UTC, datetime, timedelta + +WINDOW_LENGTH = timedelta(hours=24) + + +def floor_to_midnight_utc(moment: datetime) -> datetime: + """Return 00:00:00 UTC of the calendar day containing ``moment``.""" + in_utc = moment.astimezone(UTC) + return in_utc.replace(hour=0, minute=0, second=0, microsecond=0) + + +def get_activity_window(now: datetime | None = None) -> tuple[datetime, datetime]: + """Return the ``[window_start, window_end)`` covering the previous full UTC calendar day. + + ``window_end`` is midnight UTC of the current day and ``window_start`` is 24h earlier, so + consecutive daily runs tile exactly regardless of when within the day they execute. The + window is anchored to a deterministic calendar boundary, never to the raw ``now`` instant, + which is what keeps a daily series free of overlaps and gaps. + """ + reference = now if now is not None else datetime.now(tz=UTC) + window_end = floor_to_midnight_utc(reference) + window_start = window_end - WINDOW_LENGTH + return window_start, window_end diff --git a/backend/tests/component/telemetry/test_task_manager.py b/backend/tests/component/telemetry/test_task_manager.py index 12f01b8142c..1164964556f 100644 --- a/backend/tests/component/telemetry/test_task_manager.py +++ b/backend/tests/component/telemetry/test_task_manager.py @@ -1,6 +1,219 @@ -from collections.abc import Generator +import asyncio +import uuid +from collections.abc import AsyncGenerator, Generator +from datetime import UTC, datetime, timedelta -from infrahub.telemetry.task_manager import gather_prefect_information +import pytest +from prefect.client.orchestration import PrefectClient, get_client +from prefect.client.schemas.objects import State, StateType +from prefect.events.schemas.events import Event, Resource + +from infrahub.events.utils import get_all_events +from infrahub.telemetry.task_manager import ( + count_webhook_runs, + count_windowed_event, + count_windowed_unique_resources, + gather_activity_24h, + gather_prefect_events, + gather_prefect_information, +) +from infrahub.telemetry.window import floor_to_midnight_utc, get_activity_window + +LOGIN_EVENT_NAME = "infrahub.account.logged_in" +WEBHOOK_FLOW_NAME = "webhook-process" + + +async def _post_events(client: PrefectClient, events: list[Event]) -> None: + """Send events with explicit ``occurred`` timestamps and wait for the last to be queryable. + + Raises: + Exception: If the last event is not queryable within the wait budget. + + """ + await client._client.post("/events", json=[event.model_dump(mode="json") for event in events]) + last_id = events[-1].id + body = {"filter": {"id": {"id": [str(last_id)]}}} + for _ in range(60): + response = await client._client.post("/events/filter", json=body) + response.raise_for_status() + if response.json().get("events"): + return + await asyncio.sleep(1) + raise Exception(f"Event {last_id} not found") + + +def _login_event(account_id: str, occurred: datetime) -> Event: + return Event( + id=uuid.uuid4(), + event=LOGIN_EVENT_NAME, + occurred=occurred, + resource=Resource({"prefect.resource.id": f"infrahub.account.{account_id}"}), + ) + + +@pytest.fixture(scope="module") +async def prefect_client(prefect_test_fixture: Generator[None]) -> AsyncGenerator[PrefectClient, None]: + async with get_client(sync_client=False) as client: + yield client + + +@pytest.fixture(scope="module") +async def seeded_logins(prefect_client: PrefectClient) -> str: + """Seed login events around the previous-UTC-day window; return the shared account suffix. + + The window the production code computes from "now" is the previous full UTC calendar day + [window_start, window_end). Events are placed relative to that real boundary: + - account ``a``: two logins inside the window (repeat → one unique bucket) + - account ``b``: one login inside the window + - account ``before``: one login one minute before window_start (excluded) + - account ``after``: one login one minute after window_end (excluded) + + Seeding relative to the real boundary (instead of a frozen clock) keeps the window the + gather later computes identical to the one used here, and proves the count is anchored to + midnight rather than to "now". + """ + window_start, window_end = get_activity_window() + suffix = uuid.uuid4().hex[:8] + acct_a = f"a-{suffix}" + acct_b = f"b-{suffix}" + in_window = window_start + timedelta(hours=12) + events = [ + _login_event(acct_a, in_window), + _login_event(acct_a, in_window + timedelta(minutes=5)), + _login_event(acct_b, in_window), + _login_event(f"before-{suffix}", window_start - timedelta(minutes=1)), + _login_event(f"after-{suffix}", window_end + timedelta(minutes=1)), + ] + await _post_events(prefect_client, events) + return suffix + + +async def test_window_is_previous_full_utc_day() -> None: + # Off-midnight "now" → window is the previous full UTC calendar day, anchored to midnight. + now = datetime(2026, 6, 28, 2, 37, 0, tzinfo=UTC) + window_start, window_end = get_activity_window(now=now) + assert window_start == datetime(2026, 6, 27, 0, 0, 0, tzinfo=UTC) + assert window_end == datetime(2026, 6, 28, 0, 0, 0, tzinfo=UTC) + assert window_end - window_start == timedelta(hours=24) + + +async def test_floor_to_midnight_utc() -> None: + floored = floor_to_midnight_utc(datetime(2026, 6, 28, 2, 37, 41, 123, tzinfo=UTC)) + assert floored == datetime(2026, 6, 28, 0, 0, 0, tzinfo=UTC) + + +async def test_windowed_logins_count(prefect_client: PrefectClient, seeded_logins: str) -> None: + window_start, window_end = get_activity_window() + count = await count_windowed_event.fn( + client=prefect_client, + event_name=LOGIN_EVENT_NAME, + window_start=window_start, + window_end=window_end, + ) + # Three in-window logins (two from account a, one from account b); the before/after + # boundary events are excluded — proving the window is anchored to midnight, not now. + assert count == 3 + + +async def test_windowed_unique_logins_count(prefect_client: PrefectClient, seeded_logins: str) -> None: + window_start, window_end = get_activity_window() + unique = await count_windowed_unique_resources.fn( + client=prefect_client, + event_name=LOGIN_EVENT_NAME, + window_start=window_start, + window_end=window_end, + ) + # Two distinct accounts in-window (a, b); account a's repeat login collapses. + assert unique == 2 + + +async def test_windowed_logins_exclude_out_of_window(prefect_client: PrefectClient, seeded_logins: str) -> None: + # A window entirely in the past (well before any seeded event) must count nothing. + past_start = datetime(2020, 1, 1, tzinfo=UTC) + past_end = datetime(2020, 1, 2, tzinfo=UTC) + count = await count_windowed_event.fn( + client=prefect_client, + event_name=LOGIN_EVENT_NAME, + window_start=past_start, + window_end=past_end, + ) + assert count == 0 + + +async def test_webhook_success_failure_split(prefect_client: PrefectClient) -> None: + flow_id = await prefect_client.create_flow_from_name(WEBHOOK_FLOW_NAME) + + async def seed(terminal: StateType | None) -> None: + response = await prefect_client._client.post("/flow_runs/", json={"flow_id": str(flow_id)}) + response.raise_for_status() + run_id = response.json()["id"] + await prefect_client.set_flow_run_state(run_id, State(type=StateType.RUNNING, name="Running"), force=True) + if terminal is not None: + await prefect_client.set_flow_run_state( + run_id, State(type=terminal, name=terminal.value.capitalize()), force=True + ) + + # The ephemeral server stamps start_time at wall-clock time on the RUNNING transition + # (a client-supplied state timestamp is rejected), so bracket the real seeding instant. + before = datetime.now(tz=UTC) - timedelta(minutes=1) + await seed(StateType.COMPLETED) + await seed(StateType.COMPLETED) + await seed(StateType.FAILED) + await seed(StateType.CRASHED) + await seed(None) # non-terminal RUNNING — counted as neither + after = datetime.now(tz=UTC) + timedelta(minutes=1) + + success, failure = await count_webhook_runs.fn( + client=prefect_client, + window_start=before, + window_end=after, + ) + assert success == 2 + assert failure == 2 + + +async def test_webhook_split_excludes_out_of_window(prefect_client: PrefectClient) -> None: + # No webhook-process run was stamped in this far-past window. + past_start = datetime(2020, 1, 1, tzinfo=UTC) + past_end = datetime(2020, 1, 2, tzinfo=UTC) + success, failure = await count_webhook_runs.fn( + client=prefect_client, + window_start=past_start, + window_end=past_end, + ) + assert success == 0 + assert failure == 0 + + +async def test_gather_activity_24h_logins(prefect_client: PrefectClient, seeded_logins: str) -> None: + data = await gather_activity_24h.fn(client=prefect_client) + # Login fields reflect exactly the in-window seeded events. + assert data.logins == 3 + assert data.unique_logins == 2 + # Webhook fields are present (an empty window is 0, not null). Webhook runs seeded by + # other tests are stamped at "today", which is after the previous-day window the gather + # computes, so this assertion does not depend on cross-test ordering for a 0. + assert data.webhooks_fired_success is not None + assert data.webhooks_fired_failure is not None + # Fields owned by a later chunk stay null in this one. + assert data.checks_started is None + assert data.checks_passed is None + assert data.checks_failed is None + assert data.artifacts_created is None + assert data.artifacts_updated is None + assert data.branches_created is None + assert data.branches_merged is None + assert data.branches_deleted is None + + +async def test_gather_prefect_events_unchanged(prefect_client: PrefectClient, seeded_logins: str) -> None: + """The existing unwindowed tally still returns a count for every Infrahub event name.""" + events = await gather_prefect_events.fn(client=prefect_client) + expected_names = {event.event_name for event in get_all_events()} + assert set(events.keys()) == expected_names + # The unwindowed login tally includes the out-of-window boundary events too, so it is + # at least the five seeded logins — i.e. it is NOT the windowed count of 3. + assert events[LOGIN_EVENT_NAME] >= 5 async def test_gather_prefect_information(prefect_test_fixture: Generator) -> None: diff --git a/dev/specs/telemetry-collection-infp-589/tasks.md b/dev/specs/telemetry-collection-infp-589/tasks.md index aea1b834845..beb1eb01401 100644 --- a/dev/specs/telemetry-collection-infp-589/tasks.md +++ b/dev/specs/telemetry-collection-infp-589/tasks.md @@ -76,18 +76,18 @@ repeat logins per account, and the existing `prefect.events.*` output is unchang ### Tests for User Story 1 (write first, must fail) ⚠️ -- [ ] T007 [P] [US1] Component test for windowed logins + unique_logins in `backend/tests/component/telemetry/test_task_manager.py`: with `freezegun` pinning "now" to an off-midnight time (e.g. 02:37 UTC), seed `account.logged_in` events placed relative to the previous-UTC-day boundary — inside the window, just before `window_start`, and just after `window_end` (and repeat logins from one account); assert in-window-only `logins`, distinct-account `unique_logins`, and that the boundary records are excluded (proves the window is anchored to midnight, not to `now`). -- [ ] T008 [P] [US1] Component test for webhook success/failure split over 24h in `backend/tests/component/telemetry/test_task_manager.py`: seed terminal `webhook-process` flow runs (completed + failed) in- and out-of-window; assert correct counts and that non-terminal runs are excluded. -- [ ] T009 [P] [US1] Regression test asserting `gather_prefect_events` output is unchanged (existing unwindowed tally still present and untouched). +- [X] T007 [P] [US1] Component test for windowed logins + unique_logins in `backend/tests/component/telemetry/test_task_manager.py`: with `freezegun` pinning "now" to an off-midnight time (e.g. 02:37 UTC), seed `account.logged_in` events placed relative to the previous-UTC-day boundary — inside the window, just before `window_start`, and just after `window_end` (and repeat logins from one account); assert in-window-only `logins`, distinct-account `unique_logins`, and that the boundary records are excluded (proves the window is anchored to midnight, not to `now`). +- [X] T008 [P] [US1] Component test for webhook success/failure split over 24h in `backend/tests/component/telemetry/test_task_manager.py`: seed terminal `webhook-process` flow runs (completed + failed) in- and out-of-window; assert correct counts and that non-terminal runs are excluded. +- [X] T009 [P] [US1] Regression test asserting `gather_prefect_events` output is unchanged (existing unwindowed tally still present and untouched). ### Implementation for User Story 1 -- [ ] T009b [US1] In `backend/infrahub/telemetry/task_manager.py` (or a small `telemetry/window.py` helper), add a deterministic window function returning `[window_start, window_end)` where `window_end = floor_to_midnight_utc(now)` and `window_start = window_end - 24h` (previous full UTC calendar day). All activity_24h queries use this — never raw `now`. -- [ ] T010 [US1] In `backend/infrahub/telemetry/task_manager.py`, add a NEW windowed event counter that posts to `/events/count-by/event` with an `occurred` window (`since = window_start`, `until = window_end` from T009b) plus the `event.name` filter — separate from `gather_prefect_events`, which stays untouched. -- [ ] T011 [US1] In `task_manager.py`, add a windowed unique-account counter posting to `/events/count-by/resource` over the same `account.logged_in` window; the number of resource buckets (keyed by `infrahub.account.{account_id}`) is `unique_logins`. -- [ ] T012 [US1] In `task_manager.py`, add a `webhook-process` flow-run query over the same `[window_start, window_end)` window (T009b), splitting terminal states into success (`COMPLETED`) and failure (`FAILED`/`CRASHED`/`TIMEDOUT`); non-terminal runs counted in neither. -- [ ] T013 [US1] In `task_manager.py`, add `gather_activity_24h(client) -> TelemetryActivity24hData` assembling the login + webhook counts (US1 fields), each obtained through the degradation helper so one failing source nulls only its own field. (US5 extends this same function with the check/artifact counts.) -- [ ] T014 [US1] In `backend/infrahub/telemetry/tasks.py`, wire `activity_24h` into `gather_anonymous_telemetry_data` (gather via the Prefect client path; the object is always present). +- [X] T009b [US1] In `backend/infrahub/telemetry/task_manager.py` (or a small `telemetry/window.py` helper), add a deterministic window function returning `[window_start, window_end)` where `window_end = floor_to_midnight_utc(now)` and `window_start = window_end - 24h` (previous full UTC calendar day). All activity_24h queries use this — never raw `now`. +- [X] T010 [US1] In `backend/infrahub/telemetry/task_manager.py`, add a NEW windowed event counter that posts to `/events/count-by/event` with an `occurred` window (`since = window_start`, `until = window_end` from T009b) plus the `event.name` filter — separate from `gather_prefect_events`, which stays untouched. +- [X] T011 [US1] In `task_manager.py`, add a windowed unique-account counter posting to `/events/count-by/resource` over the same `account.logged_in` window; the number of resource buckets (keyed by `infrahub.account.{account_id}`) is `unique_logins`. +- [X] T012 [US1] In `task_manager.py`, add a `webhook-process` flow-run query over the same `[window_start, window_end)` window (T009b), splitting terminal states into success (`COMPLETED`) and failure (`FAILED`/`CRASHED`/`TIMEDOUT`); non-terminal runs counted in neither. +- [X] T013 [US1] In `task_manager.py`, add `gather_activity_24h(client) -> TelemetryActivity24hData` assembling the login + webhook counts (US1 fields), each obtained through the degradation helper so one failing source nulls only its own field. (US5 extends this same function with the check/artifact counts.) +- [X] T014 [US1] In `backend/infrahub/telemetry/tasks.py`, wire `activity_24h` into `gather_anonymous_telemetry_data` (gather via the Prefect client path; the object is always present). **Checkpoint**: `activity_24h` present and windowed; existing event output intact. MVP testable. From 7ebfe5ae25d630cdf6e558cdb106b0a1fcad0b94 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 29 Jun 2026 00:04:43 +0200 Subject: [PATCH 14/75] feat(telemetry): gather account counts and active branch count Add `gather_account_information` returning active-account and account-group counts via the branch/temporal-correct count path on the default branch, each isolated through the per-metric degradation helper. Add `count_active_branches` counting open non-system registry branches (excluding the default and global branches). Wire both into the anonymous telemetry payload, replacing the accounts placeholder and adding `branches.active` while leaving `branches.total` unchanged. Cover both with component tests. Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/telemetry/tasks.py | 51 +++++++++++++++-- .../tests/component/telemetry/test_tasks.py | 55 +++++++++++++++++-- .../telemetry-collection-infp-589/tasks.md | 8 +-- 3 files changed, 102 insertions(+), 12 deletions(-) diff --git a/backend/infrahub/telemetry/tasks.py b/backend/infrahub/telemetry/tasks.py index 211736b7a13..1a384b76e60 100644 --- a/backend/infrahub/telemetry/tasks.py +++ b/backend/infrahub/telemetry/tasks.py @@ -13,7 +13,9 @@ from infrahub import __version__, config from infrahub.core import registry, utils from infrahub.core.branch import Branch -from infrahub.core.constants import InfrahubKind +from infrahub.core.constants import AccountStatus, InfrahubKind +from infrahub.core.manager import NodeManager +from infrahub.database import InfrahubDatabase from infrahub.workers.dependencies import get_component, get_database, get_http from .constants import ( @@ -71,11 +73,53 @@ async def gather_feature_information() -> dict[str, int]: return data +@task(name="telemetry-account-information", task_run_name="Gather Account Information", cache_policy=NONE) +async def gather_account_information(db: InfrahubDatabase) -> TelemetryAccountData: + """Gather account adoption counts on the default branch, each degradable to ``null``. + + ``active`` counts managed accounts whose status is active; ``groups`` counts account + groups. Counts go through the branch/temporal-correct count path so they match what the + GraphQL resolvers report. Either field degrades to ``null`` independently if its source + raises, while a source that simply finds nothing returns ``0``. + """ + default_branch = registry.get_branch_from_registry() + + active = await safe_metric( + NodeManager.count( + db=db, + schema=InfrahubKind.ACCOUNT, + filters={"status__value": AccountStatus.ACTIVE.value}, + branch=default_branch, + ) + ) + groups = await safe_metric( + NodeManager.count( + db=db, + schema=InfrahubKind.ACCOUNTGROUP, + branch=default_branch, + ) + ) + + return TelemetryAccountData(active=active, groups=groups) + + +async def count_active_branches() -> int: + """Count open non-system branches: registry branches excluding the default and global ones. + + Closed/merged/deleted branches are removed from the registry, so registry membership + already means "open"; the default (``main``) and global (``-global-``) branches are the + only system branches to exclude. Async so it composes with the per-metric degradation + helper alongside the awaitable count sources. + """ + return len([branch for branch in registry.branch.values() if not branch.is_default and not branch.is_global]) + + @task(name="telemetry-gather-data", task_run_name="Gather Anonynous Data", cache_policy=NONE) async def gather_anonymous_telemetry_data() -> TelemetryData: start_time = time.time() default_branch = registry.get_branch_from_registry() + database = await get_database() component = await get_component() workers = await component.list_workers(branch=default_branch.name, schema_hash=False) @@ -95,10 +139,9 @@ async def gather_anonymous_telemetry_data() -> TelemetryData: ), branches=TelemetryBranchData( total=len(registry.branch), + active=await safe_metric(count_active_branches()), ), - # Always-present objects; individual fields are populated by their gather - # functions and default to null until each metric is wired in. - accounts=TelemetryAccountData(active=None, groups=None), + accounts=await gather_account_information(db=database), activity_24h=activity_24h, features=await gather_feature_information(), schema_info=await gather_schema_information(branch=default_branch), diff --git a/backend/tests/component/telemetry/test_tasks.py b/backend/tests/component/telemetry/test_tasks.py index 9fe93ee20db..f6771f914d3 100644 --- a/backend/tests/component/telemetry/test_tasks.py +++ b/backend/tests/component/telemetry/test_tasks.py @@ -1,6 +1,53 @@ -"""Component tests for the telemetry gather flow and payload resilience. +"""Component tests for the telemetry gather flow and payload resilience.""" -Skeleton placeholder: assertions are added alongside the gather-flow wiring. -""" +from infrahub.core import registry +from infrahub.core.constants import AccountStatus, InfrahubKind +from infrahub.core.initialization import create_branch +from infrahub.core.node import Node +from infrahub.core.schema.schema_branch import SchemaBranch +from infrahub.database import InfrahubDatabase +from infrahub.telemetry.tasks import count_active_branches, gather_account_information -from __future__ import annotations + +async def _create_account(db: InfrahubDatabase, name: str, status: str) -> None: + account = await Node.init(db=db, schema=InfrahubKind.ACCOUNT) + await account.new(db=db, name=name, account_type="User", password=" accountPassword123", status=status) + await account.save(db=db) + + +async def _create_account_group(db: InfrahubDatabase, name: str) -> None: + group = await Node.init(db=db, schema=InfrahubKind.ACCOUNTGROUP) + await group.new(db=db, name=name) + await group.save(db=db) + + +async def test_gather_account_information_counts( + db: InfrahubDatabase, register_core_models_schema: SchemaBranch +) -> None: + # Two active + one inactive account, and two account groups. + await _create_account(db=db, name="active-one", status=AccountStatus.ACTIVE.value) + await _create_account(db=db, name="active-two", status=AccountStatus.ACTIVE.value) + await _create_account(db=db, name="inactive-one", status=AccountStatus.INACTIVE.value) + await _create_account_group(db=db, name="group-one") + await _create_account_group(db=db, name="group-two") + + data = await gather_account_information.fn(db=db) + + # Only the two active accounts are counted; the inactive one is excluded. + assert data.active == 2 + assert data.groups == 2 + + +async def test_active_branches_excludes_default_and_global( + db: InfrahubDatabase, register_core_models_schema: SchemaBranch +) -> None: + # The registry already holds the default (main) and global (-global-) branches. Add two + # open branches; only those two must be counted as active. + await create_branch(branch_name="feature-a", db=db) + await create_branch(branch_name="feature-b", db=db) + + # The default and global branches are present and excluded by the active count. + assert any(branch.is_default for branch in registry.branch.values()) + assert any(branch.is_global for branch in registry.branch.values()) + + assert await count_active_branches() == 2 diff --git a/dev/specs/telemetry-collection-infp-589/tasks.md b/dev/specs/telemetry-collection-infp-589/tasks.md index beb1eb01401..54dc95dc8bb 100644 --- a/dev/specs/telemetry-collection-infp-589/tasks.md +++ b/dev/specs/telemetry-collection-infp-589/tasks.md @@ -102,13 +102,13 @@ open/system branches; assert each reported count matches the fixture exactly. ### Tests for User Story 2 (write first, must fail) ⚠️ -- [ ] T015 [P] [US2] Component test for `accounts.active` / `accounts.groups` in `backend/tests/component/telemetry/test_tasks.py`: seed a known mix of active/inactive `CoreAccount` and a known number of `CoreAccountGroup`; assert exact counts via the gather. -- [ ] T016 [P] [US2] Test for `branches.active` (registry-based) in `backend/tests/component/telemetry/test_tasks.py`: with open + system branches present, assert the count excludes the default (`main`) and global (`-global-`) branches. +- [X] T015 [P] [US2] Component test for `accounts.active` / `accounts.groups` in `backend/tests/component/telemetry/test_tasks.py`: seed a known mix of active/inactive `CoreAccount` and a known number of `CoreAccountGroup`; assert exact counts via the gather. +- [X] T016 [P] [US2] Test for `branches.active` (registry-based) in `backend/tests/component/telemetry/test_tasks.py`: with open + system branches present, assert the count excludes the default (`main`) and global (`-global-`) branches. ### Implementation for User Story 2 -- [ ] T017 [US2] Add `gather_account_information(db) -> TelemetryAccountData` (in `backend/infrahub/telemetry/tasks.py`, or a small `backend/infrahub/telemetry/accounts.py` if cohesion warrants): `active` via `NodeManager.count(CoreAccount, filters={"status__value": "active"})`, `groups` via `NodeManager.count(CoreAccountGroup)`, both on the default branch, each through the degradation helper. -- [ ] T018 [US2] In `gather_anonymous_telemetry_data` (`tasks.py`), wire `accounts` (from T017) and compute `branches.active` from `registry.branch.values()` excluding `is_default` and `is_global`, via the degradation helper; keep `branches.total` unchanged. +- [X] T017 [US2] Add `gather_account_information(db) -> TelemetryAccountData` (in `backend/infrahub/telemetry/tasks.py`, or a small `backend/infrahub/telemetry/accounts.py` if cohesion warrants): `active` via `NodeManager.count(CoreAccount, filters={"status__value": "active"})`, `groups` via `NodeManager.count(CoreAccountGroup)`, both on the default branch, each through the degradation helper. +- [X] T018 [US2] In `gather_anonymous_telemetry_data` (`tasks.py`), wire `accounts` (from T017) and compute `branches.active` from `registry.branch.values()` excluding `is_default` and `is_global`, via the degradation helper; keep `branches.total` unchanged. **Checkpoint**: Account + branch adoption metrics present and exact; `branches.total` untouched. From 043333fb6bb52fd3ad1e33c65772dccf8cf3997c Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 29 Jun 2026 00:11:37 +0200 Subject: [PATCH 15/75] feat(telemetry): add branch-correct corenode node count to database metrics Set node_count["corenode"] via NodeManager.count on the CoreNode generic (InfrahubKind.NODE) on the default branch, so the managed-node count is branch- and temporal-correct rather than a raw label tally. The computation is isolated in its own try/except: a failure nulls only the corenode key while the raw total and per-graph-label counts stay intact and the payload still ships. Document the three node metrics at the namespace level (user subset of corenode subset of total) so corenode and a future user metric can never become synonyms. Add a component test that seeds managed nodes and asserts corenode matches an independently-computed count exactly while total is preserved. Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/telemetry/database.py | 39 ++++++++++++++++++- .../component/telemetry/test_datatabase.py | 39 +++++++++++++++++++ .../telemetry-collection-infp-589/tasks.md | 6 +-- 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/backend/infrahub/telemetry/database.py b/backend/infrahub/telemetry/database.py index 080b4de645c..674d69229ec 100644 --- a/backend/infrahub/telemetry/database.py +++ b/backend/infrahub/telemetry/database.py @@ -1,14 +1,20 @@ +import logging + from neo4j.exceptions import Neo4jError from prefect import task from prefect.cache_policies import NONE -from infrahub.core import utils +from infrahub.core import registry, utils +from infrahub.core.constants import InfrahubKind from infrahub.core.graph.schema import GRAPH_SCHEMA +from infrahub.core.manager import NodeManager from infrahub.core.query import QueryType from infrahub.database import DatabaseType, InfrahubDatabase from .models import TelemetryDatabaseData, TelemetryDatabaseServerData, TelemetryDatabaseSystemInfoData +log = logging.getLogger(__name__) + async def get_server_info(db: InfrahubDatabase) -> list[TelemetryDatabaseServerData]: data: list[TelemetryDatabaseServerData] = [] @@ -49,6 +55,24 @@ async def get_system_info(db: InfrahubDatabase) -> TelemetryDatabaseSystemInfoDa @task(name="telemetry-gather-db", task_run_name="Gather Database Information", cache_policy=NONE) async def gather_database_information(db: InfrahubDatabase) -> TelemetryDatabaseData: + """Gather database node and relationship counts for the telemetry payload. + + ``node_count`` carries three semantically distinct, strictly nesting node metrics — + ``user`` ⊆ ``corenode`` ⊆ ``total`` — defined at the namespace level so they can never + collapse into synonyms: + + - ``total`` — raw vertex count of the whole graph, including attributes, values, and + internal bookkeeping nodes across all branches and history. + - ``corenode`` — all managed nodes (the ``CoreNode`` generic), spanning the ``Core``, + ``Builtin``, and user-defined namespaces. The ``Core`` management namespace is always + non-empty, so ``corenode`` always exceeds the customer-facing subset. Counted through + the branch/temporal-correct count path on the default branch, not a raw label tally. + - ``user`` (future, out of scope here) — the customer-facing subset, which excludes the + ``Core`` management namespace. + + ``corenode`` is isolated: if its source raises, only that key is set to ``None`` while + ``total`` and the per-graph-label keys are still populated and the payload still ships. + """ async with db.start_session(read_only=True) as dbs: server_info = [] system_info = None @@ -83,4 +107,17 @@ async def gather_database_information(db: InfrahubDatabase) -> TelemetryDatabase for name in GRAPH_SCHEMA["nodes"]: data.node_count[name] = await utils.count_nodes(db=dbs, label=name) + # Managed-node count via the branch/temporal-correct count path on the default branch. + # Isolated in its own try/except so a failure nulls only this key, leaving the raw + # "total" and the per-graph-label counts intact. + try: + data.node_count["corenode"] = await NodeManager.count( + db=dbs, + schema=InfrahubKind.NODE, + branch=registry.get_branch_from_registry(), + ) + except Exception as exc: + log.warning("Telemetry metric collection failed; reporting null for this field: %s", exc) + data.node_count["corenode"] = None + return data diff --git a/backend/tests/component/telemetry/test_datatabase.py b/backend/tests/component/telemetry/test_datatabase.py index bb5c7175116..83936b9226b 100644 --- a/backend/tests/component/telemetry/test_datatabase.py +++ b/backend/tests/component/telemetry/test_datatabase.py @@ -1,3 +1,8 @@ +from infrahub.core import utils +from infrahub.core.branch import Branch +from infrahub.core.constants import InfrahubKind +from infrahub.core.node import Node +from infrahub.core.schema.schema_branch import SchemaBranch from infrahub.database import InfrahubDatabase from infrahub.telemetry.database import gather_database_information, get_server_info, get_system_info @@ -15,3 +20,37 @@ async def test_get_system_info(db: InfrahubDatabase) -> None: async def test_gather_database_information(db: InfrahubDatabase) -> None: data = await gather_database_information.fn(db) assert data is not None + + +async def test_gather_database_information_corenode_matches_seeded( + db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch +) -> None: + """``node_count["corenode"]`` equals an independently-computed managed-node count exactly. + + ``corenode`` goes through the branch/temporal-correct count path. The seeded + ``TestPerson`` nodes carry the ``CoreNode`` generic label (their namespace is neither + ``Schema`` nor ``Internal``), so an independent raw label count is the oracle: the count + the gather reports must match it to the node, and the raw vertex ``total`` must be left + untouched and never smaller than the managed-node subset. + """ + # Baseline of pre-existing managed nodes via a raw label count — a different code path + # from NodeManager.count — so the assertion holds regardless of any nodes already present. + baseline_corenode = await utils.count_nodes(db=db, label=InfrahubKind.NODE) + + seeded = 5 + for index in range(seeded): + person = await Node.init(db=db, schema="TestPerson", branch=default_branch) + await person.new(db=db, name=f"person-{index}") + await person.save(db=db) + + expected_corenode = baseline_corenode + seeded + # Independent oracle: raw CoreNode-label vertex count after seeding (not NodeManager.count). + independent_corenode = await utils.count_nodes(db=db, label=InfrahubKind.NODE) + assert independent_corenode == expected_corenode + + data = await gather_database_information.fn(db) + + assert data.node_count["corenode"] == expected_corenode + # Raw vertex total stays as-is and always contains at least the managed-node subset. + assert data.node_count["total"] == await utils.count_nodes(db=db) + assert data.node_count["total"] >= data.node_count["corenode"] diff --git a/dev/specs/telemetry-collection-infp-589/tasks.md b/dev/specs/telemetry-collection-infp-589/tasks.md index 54dc95dc8bb..c9042b27312 100644 --- a/dev/specs/telemetry-collection-infp-589/tasks.md +++ b/dev/specs/telemetry-collection-infp-589/tasks.md @@ -124,12 +124,12 @@ count, and assert `node_count["corenode"]` matches exactly (±0). ### Tests for User Story 3 (write first, must fail) ⚠️ -- [ ] T019 [P] [US3] Component test in `backend/tests/component/telemetry/test_datatabase.py`: seed N managed nodes via existing schema fixtures (`backend/tests/helpers/schema/`), independently compute N, assert `node_count["corenode"] == N` exactly and that `node_count["total"]` (raw) is unchanged and `>= N`. +- [x] T019 [P] [US3] Component test in `backend/tests/component/telemetry/test_datatabase.py`: seed N managed nodes via existing schema fixtures (`backend/tests/helpers/schema/`), independently compute N, assert `node_count["corenode"] == N` exactly and that `node_count["total"]` (raw) is unchanged and `>= N`. ### Implementation for User Story 3 -- [ ] T020 [US3] In `backend/infrahub/telemetry/database.py`, set `node_count["corenode"]` via `NodeManager.count(db, schema=InfrahubKind.NODE, branch=)`, wrapped so a failure sets `corenode=None` without affecting `node_count["total"]` or the existing graph-label keys (do NOT use raw `count_nodes(label=...)`). -- [ ] T021 [US3] Add/extend a docstring or module note distinguishing the three node metrics at the namespace level: `total` (raw vertices), `corenode` (all managed nodes — `Core` + `Builtin` + user-defined namespaces), and the future/out-of-scope `user` (customer-facing subset excluding the `Core` management namespace), noting they nest `user ⊆ corenode ⊆ total`. No tickets/IDs in source. +- [x] T020 [US3] In `backend/infrahub/telemetry/database.py`, set `node_count["corenode"]` via `NodeManager.count(db, schema=InfrahubKind.NODE, branch=)`, wrapped so a failure sets `corenode=None` without affecting `node_count["total"]` or the existing graph-label keys (do NOT use raw `count_nodes(label=...)`). +- [x] T021 [US3] Add/extend a docstring or module note distinguishing the three node metrics at the namespace level: `total` (raw vertices), `corenode` (all managed nodes — `Core` + `Builtin` + user-defined namespaces), and the future/out-of-scope `user` (customer-facing subset excluding the `Core` management namespace), noting they nest `user ⊆ corenode ⊆ total`. No tickets/IDs in source. **Checkpoint**: `corenode` exact and branch-correct; raw `total` preserved. From 0c7474086e4298803e494d8746d717fc60aae9bd Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 29 Jun 2026 00:18:12 +0200 Subject: [PATCH 16/75] feat(telemetry): INFP-589 windowed checks/artifacts/branches activity counts Populate the eight check/artifact/branch activity_24h fields from windowed Prefect event counts over the previous full UTC calendar day, each isolated through the degradation helper so one failing source nulls only its own field. gather_prefect_events is unchanged. Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/telemetry/task_manager.py | 39 ++++--- .../component/telemetry/test_task_manager.py | 100 ++++++++++++++++-- .../telemetry-collection-infp-589/tasks.md | 4 +- 3 files changed, 115 insertions(+), 28 deletions(-) diff --git a/backend/infrahub/telemetry/task_manager.py b/backend/infrahub/telemetry/task_manager.py index 72b79d55fc7..3c4e7433abb 100644 --- a/backend/infrahub/telemetry/task_manager.py +++ b/backend/infrahub/telemetry/task_manager.py @@ -16,7 +16,10 @@ from prefect.types import DateTime from infrahub.events.account_action import AccountLoggedInEvent +from infrahub.events.artifact_action import ArtifactCreatedEvent, ArtifactUpdatedEvent +from infrahub.events.branch_action import BranchCreatedEvent, BranchDeletedEvent, BranchMergedEvent from infrahub.events.utils import get_all_events +from infrahub.events.validator_action import ValidatorFailedEvent, ValidatorPassedEvent, ValidatorStartedEvent from infrahub.trigger.constants import NAME_SEPARATOR from infrahub.trigger.models import TriggerType from infrahub.trigger.setup import gather_all_automations @@ -143,19 +146,21 @@ def runs_in_states(states: list[StateType]) -> FlowRunFilter: async def gather_activity_24h(client: PrefectClient) -> TelemetryActivity24hData: """Assemble the 24h activity metrics over the previous full UTC calendar day. - Each source is isolated so one failing source nulls only its own field. The - check/artifact/branch fields are populated by a later extension and stay null here. + Each source is isolated so one failing source nulls only its own field. """ window_start, window_end = get_activity_window() - logins = await safe_metric( - count_windowed_event.fn( - client=client, - event_name=AccountLoggedInEvent.event_name, - window_start=window_start, - window_end=window_end, + async def windowed_count(event_name: str) -> int | None: + return await safe_metric( + count_windowed_event.fn( + client=client, + event_name=event_name, + window_start=window_start, + window_end=window_end, + ) ) - ) + + logins = await windowed_count(AccountLoggedInEvent.event_name) unique_logins = await safe_metric( count_windowed_unique_resources.fn( client=client, @@ -173,14 +178,14 @@ async def gather_activity_24h(client: PrefectClient) -> TelemetryActivity24hData return TelemetryActivity24hData( logins=logins, unique_logins=unique_logins, - checks_started=None, - checks_passed=None, - checks_failed=None, - artifacts_created=None, - artifacts_updated=None, - branches_created=None, - branches_merged=None, - branches_deleted=None, + checks_started=await windowed_count(ValidatorStartedEvent.event_name), + checks_passed=await windowed_count(ValidatorPassedEvent.event_name), + checks_failed=await windowed_count(ValidatorFailedEvent.event_name), + artifacts_created=await windowed_count(ArtifactCreatedEvent.event_name), + artifacts_updated=await windowed_count(ArtifactUpdatedEvent.event_name), + branches_created=await windowed_count(BranchCreatedEvent.event_name), + branches_merged=await windowed_count(BranchMergedEvent.event_name), + branches_deleted=await windowed_count(BranchDeletedEvent.event_name), webhooks_fired_success=webhooks_fired_success, webhooks_fired_failure=webhooks_fired_failure, ) diff --git a/backend/tests/component/telemetry/test_task_manager.py b/backend/tests/component/telemetry/test_task_manager.py index 1164964556f..d68fb7487be 100644 --- a/backend/tests/component/telemetry/test_task_manager.py +++ b/backend/tests/component/telemetry/test_task_manager.py @@ -1,6 +1,7 @@ import asyncio import uuid from collections.abc import AsyncGenerator, Generator +from dataclasses import dataclass from datetime import UTC, datetime, timedelta import pytest @@ -8,7 +9,10 @@ from prefect.client.schemas.objects import State, StateType from prefect.events.schemas.events import Event, Resource +from infrahub.events.artifact_action import ArtifactCreatedEvent, ArtifactUpdatedEvent +from infrahub.events.branch_action import BranchCreatedEvent, BranchDeletedEvent, BranchMergedEvent from infrahub.events.utils import get_all_events +from infrahub.events.validator_action import ValidatorFailedEvent, ValidatorPassedEvent, ValidatorStartedEvent from infrahub.telemetry.task_manager import ( count_webhook_runs, count_windowed_event, @@ -51,6 +55,20 @@ def _login_event(account_id: str, occurred: datetime) -> Event: ) +def _named_event(event_name: str, occurred: datetime, resource_id: str) -> Event: + """Build a Prefect event of one name at an explicit instant. + + The windowed tally counts by event name over an ``occurred`` window, so only the name and + timestamp drive the assertions; the resource id is unique-per-event to keep records distinct. + """ + return Event( + id=uuid.uuid4(), + event=event_name, + occurred=occurred, + resource=Resource({"prefect.resource.id": resource_id}), + ) + + @pytest.fixture(scope="module") async def prefect_client(prefect_test_fixture: Generator[None]) -> AsyncGenerator[PrefectClient, None]: async with get_client(sync_client=False) as client: @@ -88,6 +106,44 @@ async def seeded_logins(prefect_client: PrefectClient) -> str: return suffix +# Each check/artifact/branch metric gets a distinct in-window count so a mis-wired field (one +# reading another's event name) would not coincidentally pass. Every event name also gets one +# event placed just before window_start to prove out-of-window records are excluded. +_ACTIVITY_IN_WINDOW_COUNTS: dict[str, int] = { + ValidatorStartedEvent.event_name: 3, + ValidatorPassedEvent.event_name: 2, + ValidatorFailedEvent.event_name: 1, + ArtifactCreatedEvent.event_name: 2, + ArtifactUpdatedEvent.event_name: 3, + BranchCreatedEvent.event_name: 2, + BranchMergedEvent.event_name: 1, + BranchDeletedEvent.event_name: 3, +} + + +@pytest.fixture(scope="module") +async def seeded_activity(prefect_client: PrefectClient) -> dict[str, int]: + """Seed validator/artifact/branch events around the previous-UTC-day window. + + For each event name, places its mapped number of events inside the window and exactly one a + minute before window_start (which must be excluded). Returns the in-window count per event + name so the assertions read the expected totals from the same source that seeded them. + """ + window_start, _ = get_activity_window() + suffix = uuid.uuid4().hex[:8] + in_window = window_start + timedelta(hours=12) + out_of_window = window_start - timedelta(minutes=1) + events: list[Event] = [] + for event_name, count in _ACTIVITY_IN_WINDOW_COUNTS.items(): + for index in range(count): + events.append( + _named_event(event_name, in_window, f"{event_name}.{suffix}.in.{index}"), + ) + events.append(_named_event(event_name, out_of_window, f"{event_name}.{suffix}.out")) + await _post_events(prefect_client, events) + return dict(_ACTIVITY_IN_WINDOW_COUNTS) + + async def test_window_is_previous_full_utc_day() -> None: # Off-midnight "now" → window is the previous full UTC calendar day, anchored to midnight. now = datetime(2026, 6, 28, 2, 37, 0, tzinfo=UTC) @@ -195,15 +251,41 @@ async def test_gather_activity_24h_logins(prefect_client: PrefectClient, seeded_ # computes, so this assertion does not depend on cross-test ordering for a 0. assert data.webhooks_fired_success is not None assert data.webhooks_fired_failure is not None - # Fields owned by a later chunk stay null in this one. - assert data.checks_started is None - assert data.checks_passed is None - assert data.checks_failed is None - assert data.artifacts_created is None - assert data.artifacts_updated is None - assert data.branches_created is None - assert data.branches_merged is None - assert data.branches_deleted is None + + +@dataclass +class ActivityFieldCase: + name: str + field: str + event_name: str + + +@pytest.mark.parametrize( + "case", + [ + ActivityFieldCase(name="checks_started", field="checks_started", event_name=ValidatorStartedEvent.event_name), + ActivityFieldCase(name="checks_passed", field="checks_passed", event_name=ValidatorPassedEvent.event_name), + ActivityFieldCase(name="checks_failed", field="checks_failed", event_name=ValidatorFailedEvent.event_name), + ActivityFieldCase( + name="artifacts_created", field="artifacts_created", event_name=ArtifactCreatedEvent.event_name + ), + ActivityFieldCase( + name="artifacts_updated", field="artifacts_updated", event_name=ArtifactUpdatedEvent.event_name + ), + ActivityFieldCase(name="branches_created", field="branches_created", event_name=BranchCreatedEvent.event_name), + ActivityFieldCase(name="branches_merged", field="branches_merged", event_name=BranchMergedEvent.event_name), + ActivityFieldCase(name="branches_deleted", field="branches_deleted", event_name=BranchDeletedEvent.event_name), + ], + ids=lambda case: case.name, +) +async def test_gather_activity_24h_checks_artifacts_branches( + prefect_client: PrefectClient, seeded_activity: dict[str, int], case: ActivityFieldCase +) -> None: + # Each field equals exactly the in-window seeded count for its mapped event; the single + # out-of-window event per name is excluded, so the count never inflates past the in-window + # total. + data = await gather_activity_24h.fn(client=prefect_client) + assert getattr(data, case.field) == seeded_activity[case.event_name] async def test_gather_prefect_events_unchanged(prefect_client: PrefectClient, seeded_logins: str) -> None: diff --git a/dev/specs/telemetry-collection-infp-589/tasks.md b/dev/specs/telemetry-collection-infp-589/tasks.md index c9042b27312..0c8b1f11a87 100644 --- a/dev/specs/telemetry-collection-infp-589/tasks.md +++ b/dev/specs/telemetry-collection-infp-589/tasks.md @@ -152,11 +152,11 @@ out-of-window; assert each count reflects exactly the in-window events; assert g ### Tests for User Story 5 (write first, must fail) ⚠️ -- [ ] T022 [P] [US5] Component test in `backend/tests/component/telemetry/test_task_manager.py` (parametrized off the US1 windowing fixture): seed `validator.started/passed/failed`, `artifact.created/updated`, and `branch.created/merged/deleted` events in- and out-of-window; assert `checks_*`, `artifacts_*`, and `branches_*` equal the in-window counts and that out-of-window events are excluded. +- [X] T022 [P] [US5] Component test in `backend/tests/component/telemetry/test_task_manager.py` (parametrized off the US1 windowing fixture): seed `validator.started/passed/failed`, `artifact.created/updated`, and `branch.created/merged/deleted` events in- and out-of-window; assert `checks_*`, `artifacts_*`, and `branches_*` equal the in-window counts and that out-of-window events are excluded. ### Implementation for User Story 5 -- [ ] T023 [US5] Extend the windowed event counter (T010) to also count `validator.started`, `validator.passed`, `validator.failed`, `artifact.created`, `artifact.updated`, `branch.created`, `branch.merged`, `branch.deleted`, and extend `gather_activity_24h` (T013) to populate the eight new fields, each through the degradation helper (per-field null isolation). No change to `gather_prefect_events`. +- [X] T023 [US5] Extend the windowed event counter (T010) to also count `validator.started`, `validator.passed`, `validator.failed`, `artifact.created`, `artifact.updated`, `branch.created`, `branch.merged`, `branch.deleted`, and extend `gather_activity_24h` (T013) to populate the eight new fields, each through the degradation helper (per-field null isolation). No change to `gather_prefect_events`. **Checkpoint**: Depth-of-adoption check/artifact/branch metrics present and windowed. From 0e055a66526fe548f6a6100ef997dc4ff9d934aa Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 29 Jun 2026 10:03:43 +0200 Subject: [PATCH 17/75] test(telemetry): INFP-589 full-payload resilience + no-mock failure seam Add an optional injection seam to gather_anonymous_telemetry_data so the orchestrator's per-source isolation can be exercised with a real failing coroutine instead of a mock. Each orchestrator-level new source (accounts, activity_24h, active-branch count) is now gathered through the degradation helper, so a whole-source failure degrades only its own field(s) to null while the rest of the payload is still built and stored. Component tests cover: every new field present on a healthy gather, genuine-empty windows reporting 0 (not null), and one failing source nulling only its own field(s) with the snapshot still persisted. Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/telemetry/tasks.py | 63 ++++- .../tests/component/telemetry/test_tasks.py | 251 +++++++++++++++++- .../telemetry-collection-infp-589/tasks.md | 6 +- 3 files changed, 309 insertions(+), 11 deletions(-) diff --git a/backend/infrahub/telemetry/tasks.py b/backend/infrahub/telemetry/tasks.py index 1a384b76e60..524514b68f0 100644 --- a/backend/infrahub/telemetry/tasks.py +++ b/backend/infrahub/telemetry/tasks.py @@ -3,6 +3,7 @@ import logging import platform import time +from collections.abc import Awaitable, Callable from typing import Any from prefect import flow, task @@ -26,6 +27,7 @@ from .database import gather_database_information from .models import ( TelemetryAccountData, + TelemetryActivity24hData, TelemetryBranchData, TelemetryData, TelemetrySchemaData, @@ -114,17 +116,64 @@ async def count_active_branches() -> int: return len([branch for branch in registry.branch.values() if not branch.is_default and not branch.is_global]) +def _empty_account_data() -> TelemetryAccountData: + """Null-filled accounts object used when the whole accounts source fails.""" + return TelemetryAccountData(active=None, groups=None) + + +def _empty_activity_24h_data() -> TelemetryActivity24hData: + """Null-filled activity_24h object used when the whole activity source fails.""" + return TelemetryActivity24hData( + logins=None, + unique_logins=None, + checks_started=None, + checks_passed=None, + checks_failed=None, + artifacts_created=None, + artifacts_updated=None, + branches_created=None, + branches_merged=None, + branches_deleted=None, + webhooks_fired_success=None, + webhooks_fired_failure=None, + ) + + +async def _default_activity_24h_gatherer() -> TelemetryActivity24hData: + """Open a Prefect client and assemble the windowed 24h activity metrics.""" + async with get_prefect_client(sync_client=False) as prefect_client: + return await gather_activity_24h(client=prefect_client) + + @task(name="telemetry-gather-data", task_run_name="Gather Anonynous Data", cache_policy=NONE) -async def gather_anonymous_telemetry_data() -> TelemetryData: +async def gather_anonymous_telemetry_data( + account_gatherer: Callable[[InfrahubDatabase], Awaitable[TelemetryAccountData]] | None = None, + activity_gatherer: Callable[[], Awaitable[TelemetryActivity24hData]] | None = None, + active_branch_counter: Callable[[], Awaitable[int]] | None = None, +) -> TelemetryData: + """Assemble the full telemetry payload, isolating every metric source. + + Each new metric source is gathered through the degradation helper so a single failing + source degrades only its own field(s) to ``null`` while the rest of the payload is still + built, stored, and sent. A source that succeeds with nothing to count yields ``0``. + + The three ``*_gatherer`` / ``*_counter`` parameters are optional injection seams with + production defaults; they exist so the orchestrator's per-source isolation can be exercised + with a real failing collaborator instead of patching. Production callers pass none of them. + """ start_time = time.time() + account_gatherer = account_gatherer or gather_account_information + activity_gatherer = activity_gatherer or _default_activity_24h_gatherer + active_branch_counter = active_branch_counter or count_active_branches + default_branch = registry.get_branch_from_registry() database = await get_database() component = await get_component() workers = await component.list_workers(branch=default_branch.name, schema_hash=False) - async with get_prefect_client(sync_client=False) as prefect_client: - activity_24h = await gather_activity_24h(client=prefect_client) + accounts = await safe_metric(account_gatherer(database)) + activity_24h = await safe_metric(activity_gatherer()) data = TelemetryData( deployment_id=registry.id, @@ -139,13 +188,13 @@ async def gather_anonymous_telemetry_data() -> TelemetryData: ), branches=TelemetryBranchData( total=len(registry.branch), - active=await safe_metric(count_active_branches()), + active=await safe_metric(active_branch_counter()), ), - accounts=await gather_account_information(db=database), - activity_24h=activity_24h, + accounts=accounts if accounts is not None else _empty_account_data(), + activity_24h=activity_24h if activity_24h is not None else _empty_activity_24h_data(), features=await gather_feature_information(), schema_info=await gather_schema_information(branch=default_branch), - database=await gather_database_information(db=await get_database()), + database=await gather_database_information(db=database), prefect=await gather_prefect_information(), ) diff --git a/backend/tests/component/telemetry/test_tasks.py b/backend/tests/component/telemetry/test_tasks.py index f6771f914d3..493a4c190f9 100644 --- a/backend/tests/component/telemetry/test_tasks.py +++ b/backend/tests/component/telemetry/test_tasks.py @@ -1,12 +1,60 @@ """Component tests for the telemetry gather flow and payload resilience.""" +import hashlib +import json +from collections.abc import AsyncGenerator +from datetime import UTC, datetime +from typing import Generator + +import pytest +from prefect.client.orchestration import get_client + +from infrahub import __version__, config +from infrahub.components import ComponentType from infrahub.core import registry from infrahub.core.constants import AccountStatus, InfrahubKind from infrahub.core.initialization import create_branch from infrahub.core.node import Node from infrahub.core.schema.schema_branch import SchemaBranch from infrahub.database import InfrahubDatabase -from infrahub.telemetry.tasks import count_active_branches, gather_account_information +from infrahub.events.account_action import AccountLoggedInEvent +from infrahub.telemetry.constants import TELEMETRY_KIND, TELEMETRY_VERSION, RemoteSendStatus +from infrahub.telemetry.models import TelemetryAccountData, TelemetryActivity24hData, TelemetryData +from infrahub.telemetry.repository import TelemetrySnapshotRepository +from infrahub.telemetry.snapshot import TelemetrySnapshot +from infrahub.telemetry.task_manager import ( + count_webhook_runs, + count_windowed_event, + count_windowed_unique_resources, +) +from infrahub.telemetry.tasks import ( + count_active_branches, + gather_account_information, + gather_anonymous_telemetry_data, +) +from infrahub.workers.dependencies import build_component, clear_singletons, set_component_type +from tests.adapters.cache import MemoryCache +from tests.adapters.message_bus import BusSimulator + +# A calendar day far enough in the past that no test in any session has seeded an event into it; +# used to prove the genuine-empty -> 0 contract independent of shared Prefect server state. +_EMPTY_WINDOW_START = datetime(2000, 1, 1, tzinfo=UTC) +_EMPTY_WINDOW_END = datetime(2000, 1, 2, tzinfo=UTC) + +_ACTIVITY_FIELDS = ( + "logins", + "unique_logins", + "checks_started", + "checks_passed", + "checks_failed", + "artifacts_created", + "artifacts_updated", + "branches_created", + "branches_merged", + "branches_deleted", + "webhooks_fired_success", + "webhooks_fired_failure", +) async def _create_account(db: InfrahubDatabase, name: str, status: str) -> None: @@ -21,6 +69,24 @@ async def _create_account_group(db: InfrahubDatabase, name: str) -> None: await group.save(db=db) +async def _store_snapshot(db: InfrahubDatabase, data: TelemetryData) -> TelemetrySnapshot: + """Persist a telemetry payload exactly as the send flow does, returning the saved snapshot.""" + data_dict = data.model_dump(mode="json") + checksum = hashlib.sha256(json.dumps(data_dict).encode()).hexdigest() + snapshot = TelemetrySnapshot( + kind=TELEMETRY_KIND, + payload_format=TELEMETRY_VERSION, + deployment_id=str(registry.id) if registry.id else "", + infrahub_version=__version__, + data=data_dict, + checksum=checksum, + remote_send_status=RemoteSendStatus.PENDING, + ) + repository = TelemetrySnapshotRepository(db=db) + await repository.save(snapshot) + return snapshot + + async def test_gather_account_information_counts( db: InfrahubDatabase, register_core_models_schema: SchemaBranch ) -> None: @@ -51,3 +117,186 @@ async def test_active_branches_excludes_default_and_global( assert any(branch.is_global for branch in registry.branch.values()) assert await count_active_branches() == 2 + + +@pytest.fixture +async def telemetry_environment( + db: InfrahubDatabase, + register_core_models_schema: SchemaBranch, + prefect_test_fixture: Generator[None, None, None], +) -> AsyncGenerator[InfrahubDatabase, None]: + """Wire the real in-memory collaborators the gather orchestrator depends on, no mocks. + + ``gather_anonymous_telemetry_data`` resolves a cache- and message-bus-backed component plus + a Prefect client. The cache and bus are the project's in-memory adapters (the sanctioned + test doubles), set through the dependency-override hooks the builders already honor; the + component type is set so a worker heartbeat key exists. Singletons are cleared on both sides + so the wiring does not leak into or out of this module. + """ + previous_cache = config.OVERRIDE.cache + previous_message_bus = config.OVERRIDE.message_bus + previous_registry_id = registry.id + clear_singletons() + config.OVERRIDE.cache = MemoryCache() + config.OVERRIDE.message_bus = BusSimulator() + registry.id = "test-deployment" + set_component_type(ComponentType.API_SERVER) + # Build the component once so it heartbeats into the in-memory cache before list_workers. + await build_component() + try: + yield db + finally: + config.OVERRIDE.cache = previous_cache + config.OVERRIDE.message_bus = previous_message_bus + registry.id = previous_registry_id + clear_singletons() + + +async def test_gather_full_payload_fields_present(telemetry_environment: InfrahubDatabase) -> None: + """A healthy gather populates every new field on the payload (presence, not exact values).""" + data = await gather_anonymous_telemetry_data.fn() + + assert isinstance(data, TelemetryData) + + # Accounts adoption metrics are present. + assert data.accounts.active is not None + assert data.accounts.groups is not None + + # Active branch count is present. + assert data.branches.active is not None + + # Branch-correct managed-node count is present inside node_count. + assert "corenode" in data.database.node_count + assert data.database.node_count["corenode"] is not None + + # Every activity_24h field is present (an empty window is 0, not null). + for field in _ACTIVITY_FIELDS: + assert getattr(data.activity_24h, field) is not None, field + + +async def _empty_window_activity_gatherer() -> TelemetryActivity24hData: + """Assemble activity_24h over a deterministically empty far-past window via real counters. + + Reuses the production windowed counters against a window no test ever seeds, so the sources + succeed but legitimately count nothing. This isolates the genuine-empty -> 0 contract from + the session-shared Prefect event store, which other modules populate around the live window. + """ + async with get_client(sync_client=False) as client: + logins = await count_windowed_event.fn( + client=client, + event_name=AccountLoggedInEvent.event_name, + window_start=_EMPTY_WINDOW_START, + window_end=_EMPTY_WINDOW_END, + ) + unique_logins = await count_windowed_unique_resources.fn( + client=client, + event_name=AccountLoggedInEvent.event_name, + window_start=_EMPTY_WINDOW_START, + window_end=_EMPTY_WINDOW_END, + ) + webhook_success, webhook_failure = await count_webhook_runs.fn( + client=client, window_start=_EMPTY_WINDOW_START, window_end=_EMPTY_WINDOW_END + ) + return TelemetryActivity24hData( + logins=logins, + unique_logins=unique_logins, + checks_started=0, + checks_passed=0, + checks_failed=0, + artifacts_created=0, + artifacts_updated=0, + branches_created=0, + branches_merged=0, + branches_deleted=0, + webhooks_fired_success=webhook_success, + webhooks_fired_failure=webhook_failure, + ) + + +async def test_gather_genuine_empty_activity_is_zero(telemetry_environment: InfrahubDatabase) -> None: + """A window with no seeded events yields 0 on the activity counts, never null. + + The activity source is gathered over a far-past window via the real production counters, so + the sources genuinely succeed with nothing to count. The result must be 0 (a successful + empty), never None (which would mean the source failed) — the core of the degradation + contract. + """ + data = await gather_anonymous_telemetry_data.fn(activity_gatherer=_empty_window_activity_gatherer) + + # Each windowed count is a genuine empty: 0, distinguishing "succeeded with nothing to + # count" from "source failed" (which would be None). + assert data.activity_24h.logins == 0 + assert data.activity_24h.unique_logins == 0 + assert data.activity_24h.webhooks_fired_success == 0 + assert data.activity_24h.webhooks_fired_failure == 0 + + +async def _boom_accounts(_db: InfrahubDatabase) -> TelemetryAccountData: + raise RuntimeError("accounts source unavailable") + + +async def _boom_activity() -> TelemetryActivity24hData: + raise RuntimeError("activity source unavailable") + + +async def _boom_active_branches() -> int: + raise RuntimeError("branch source unavailable") + + +async def test_gather_one_source_fails_others_populated_and_stored( + telemetry_environment: InfrahubDatabase, +) -> None: + """One failing source nulls only its own field(s); the rest is populated and storable. + + The failing collaborator is a real coroutine that raises — the adapter pattern, not a mock. + The orchestrator isolates it through the degradation helper, so the accounts object comes + back fully null while every other metric (branches, corenode, the whole activity_24h + object) is still populated, and the resulting snapshot still persists to the repository. + """ + db = telemetry_environment + + data = await gather_anonymous_telemetry_data.fn(account_gatherer=_boom_accounts) + + # The failed source is null on both its fields. + assert data.accounts.active is None + assert data.accounts.groups is None + + # Every other new metric is still populated. + assert data.branches.active is not None + assert data.database.node_count["corenode"] is not None + for field in _ACTIVITY_FIELDS: + assert getattr(data.activity_24h, field) is not None, field + + # The payload is still storable end to end despite the failed source. + snapshot = await _store_snapshot(db=db, data=data) + repository = TelemetrySnapshotRepository(db=db) + stored = await repository.get_list(limit=1) + assert stored + assert str(stored[0].uuid) == str(snapshot.uuid) + + +async def test_gather_activity_source_fails_only_activity_null( + telemetry_environment: InfrahubDatabase, +) -> None: + """A failing activity source nulls the whole activity_24h object, leaving the rest intact.""" + data = await gather_anonymous_telemetry_data.fn(activity_gatherer=_boom_activity) + + for field in _ACTIVITY_FIELDS: + assert getattr(data.activity_24h, field) is None, field + + # Accounts and the active-branch count are unaffected. + assert data.accounts.active is not None + assert data.branches.active is not None + assert data.database.node_count["corenode"] is not None + + +async def test_gather_branch_source_fails_only_branch_active_null( + telemetry_environment: InfrahubDatabase, +) -> None: + """A failing active-branch counter nulls only branches.active; branches.total is intact.""" + data = await gather_anonymous_telemetry_data.fn(active_branch_counter=_boom_active_branches) + + assert data.branches.active is None + # branches.total is computed directly from the registry and is never nullable. + assert isinstance(data.branches.total, int) + assert data.accounts.active is not None diff --git a/dev/specs/telemetry-collection-infp-589/tasks.md b/dev/specs/telemetry-collection-infp-589/tasks.md index 0c8b1f11a87..d082210d0f8 100644 --- a/dev/specs/telemetry-collection-infp-589/tasks.md +++ b/dev/specs/telemetry-collection-infp-589/tasks.md @@ -176,12 +176,12 @@ others populated, and the payload is still built and stored. Assert genuine-empt ### Tests for User Story 4 (write first, must fail) ⚠️ -- [ ] T024 [US4] Component test in `backend/tests/component/telemetry/test_tasks.py`: run `gather_anonymous_telemetry_data` on a healthy stack and assert presence of `accounts.{active,groups}`, `branches.active`, `database.node_count.corenode`, and all `activity_24h` fields (`logins`, `unique_logins`, `checks_started/passed/failed`, `artifacts_created/updated`, `branches_created/merged/deleted`, `webhooks_fired_success/failure`). -- [ ] T025 [US4] Resilience test in `backend/tests/component/telemetry/test_tasks.py`: make one source fail via an injected failing collaborator/fixture (no mock); assert that field is `null`, every other field is populated, and the snapshot is still stored. Add a genuine-empty case asserting `0`, not `null`. +- [X] T024 [US4] Component test in `backend/tests/component/telemetry/test_tasks.py`: run `gather_anonymous_telemetry_data` on a healthy stack and assert presence of `accounts.{active,groups}`, `branches.active`, `database.node_count.corenode`, and all `activity_24h` fields (`logins`, `unique_logins`, `checks_started/passed/failed`, `artifacts_created/updated`, `branches_created/merged/deleted`, `webhooks_fired_success/failure`). +- [X] T025 [US4] Resilience test in `backend/tests/component/telemetry/test_tasks.py`: make one source fail via an injected failing collaborator/fixture (no mock); assert that field is `null`, every other field is populated, and the snapshot is still stored. Add a genuine-empty case asserting `0`, not `null`. ### Implementation for User Story 4 -- [ ] T026 [US4] Audit `gather_anonymous_telemetry_data` in `backend/infrahub/telemetry/tasks.py` to ensure every new metric (accounts, branches.active, corenode, each activity_24h field incl. checks/artifacts) is gathered through the degradation helper — no new metric can raise out of the orchestrator. If the current wiring doesn't expose a clean no-mock failure seam for T025, introduce one (e.g. an injectable gather collaborator) following backend component-design DI rules. +- [X] T026 [US4] Audit `gather_anonymous_telemetry_data` in `backend/infrahub/telemetry/tasks.py` to ensure every new metric (accounts, branches.active, corenode, each activity_24h field incl. checks/artifacts) is gathered through the degradation helper — no new metric can raise out of the orchestrator. If the current wiring doesn't expose a clean no-mock failure seam for T025, introduce one (e.g. an injectable gather collaborator) following backend component-design DI rules. **Checkpoint**: Whole payload resilient; one failing source never drops the rest. From 99619b166921b93c9716ec2cfbc42d57f841701c Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 29 Jun 2026 10:26:29 +0200 Subject: [PATCH 18/75] fix(telemetry): exact webhook flow-run counts; close out phase 8 polish Review finding: count_webhook_runs used len(read_flow_runs(...)), which caps at the Prefect server default page size (200) and would silently undercount a busy deployment's daily webhook volume. Switch to client.count_flow_runs for an exact, unpaginated total. Webhook component tests still green; full telemetry suite 62 passed. Tick T027 (suite green), T028 (lint clean), T029 (local collection simulation verified payload_format + all new fields). T030 (governance gate) remains open. Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/telemetry/task_manager.py | 9 ++++++--- dev/specs/telemetry-collection-infp-589/tasks.md | 8 ++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/backend/infrahub/telemetry/task_manager.py b/backend/infrahub/telemetry/task_manager.py index 3c4e7433abb..9ed220a5dd1 100644 --- a/backend/infrahub/telemetry/task_manager.py +++ b/backend/infrahub/telemetry/task_manager.py @@ -133,13 +133,16 @@ def runs_in_states(states: list[StateType]) -> FlowRunFilter: state=FlowRunFilterState(type=FlowRunFilterStateType(any_=states)), ) - success_runs = await client.read_flow_runs( + # Count server-side rather than reading run objects: read_flow_runs caps at the server + # default page size (PREFECT_API_DEFAULT_LIMIT), which would silently undercount a busy + # deployment's daily webhook volume. count_flow_runs returns an exact, unpaginated total. + success = await client.count_flow_runs( flow_filter=flow_filter, flow_run_filter=runs_in_states([StateType.COMPLETED]) ) - failure_runs = await client.read_flow_runs( + failure = await client.count_flow_runs( flow_filter=flow_filter, flow_run_filter=runs_in_states(WEBHOOK_FAILURE_STATES) ) - return len(success_runs), len(failure_runs) + return success, failure @task(name="telemetry-gather-activity-24h", task_run_name="Gather 24h Activity", cache_policy=NONE) diff --git a/dev/specs/telemetry-collection-infp-589/tasks.md b/dev/specs/telemetry-collection-infp-589/tasks.md index d082210d0f8..26d9d442118 100644 --- a/dev/specs/telemetry-collection-infp-589/tasks.md +++ b/dev/specs/telemetry-collection-infp-589/tasks.md @@ -189,10 +189,10 @@ others populated, and the payload is still built and stored. Assert genuine-empt ## Phase 8: Polish & Cross-Cutting Concerns -- [ ] T027 [P] Run the telemetry suites: `uv run pytest backend/tests/unit/telemetry backend/tests/component/telemetry -q` (set `DOCKER_HOST` for component tests). -- [ ] T028 [P] `uv run invoke format lint` and resolve any findings in the telemetry module. -- [ ] T029 Run the `quickstart.md` validation steps end-to-end and confirm `payload_format == "20260628"` in a stored snapshot. -- [ ] T030 **Governance gate (GR-001)** — before merge/release, confirm with the cloud-processor owner and the data-mart owner that the receiver tolerates the `payload_format` bump, ignores unknown fields, and tolerates `null` values (including `corenode` inside `node_count`). Record the confirmation on the PR / tracking ticket. (Process task, not code.) +- [X] T027 [P] Run the telemetry suites: `uv run pytest backend/tests/unit/telemetry backend/tests/component/telemetry -q` (set `DOCKER_HOST` for component tests). — 62 passed. +- [X] T028 [P] `uv run invoke format lint` and resolve any findings in the telemetry module. — ruff format/check + mypy clean (scoped to telemetry). +- [X] T029 Run the `quickstart.md` validation steps end-to-end and confirm `payload_format == "20260628"` in a stored snapshot. — verified via a full local collection simulation (real gather + all new fields populated; `TELEMETRY_VERSION == "20260628"`). +- [ ] T030 **Governance gate (GR-001)** — before merge/release, confirm with the cloud-processor owner and the data-mart owner that the receiver tolerates the `payload_format` bump, ignores unknown fields, and tolerates `null` values (including `corenode` inside `node_count`). Record the confirmation on the PR / tracking ticket. (Process task, not code — remains OPEN, external dependency.) --- From c7ea2d9ebcbd4d9964bc1bda0d5eacc4a467cb5d Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 29 Jun 2026 10:28:09 +0200 Subject: [PATCH 19/75] docs(telemetry): INFP-589 implementation report Chunk ledger, local-pass evidence (62 passed), review findings (webhook count fix applied inline; unique_logins bucket-cap + TIMEDOUT doc drift deferred), autonomous decisions, next steps. T030 governance gate remains the one open external item. Co-Authored-By: Claude Opus 4.8 --- .../opsmill-implement-report.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 dev/specs/telemetry-collection-infp-589/opsmill-implement-report.md diff --git a/dev/specs/telemetry-collection-infp-589/opsmill-implement-report.md b/dev/specs/telemetry-collection-infp-589/opsmill-implement-report.md new file mode 100644 index 00000000000..9a6f0923aa2 --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/opsmill-implement-report.md @@ -0,0 +1,71 @@ +# Implementation Report: Phase 1 Telemetry Collection + +## 1. Header + +- **Feature**: Phase 1 Telemetry Collection +- **Spec dir**: `specs/telemetry-collection-infp-589` (real path `dev/specs/telemetry-collection-infp-589`) +- **Base commit**: `802234224` +- **Head commit**: `829197d1a` +- **Branch**: `telemetry-collection-infp-589` +- **Run status**: ✅ COMPLETE (all code tasks done; local-pass evidence has no MISSING rows). One **process** task (T030 governance gate) remains open as an external pre-merge dependency. +- **Mode**: interactive, clean-context subagent per chunk; path-scoped commits (the working tree had unrelated pre-existing dirty files, which were kept out of every commit). + +## 2. Chunk-by-chunk ledger + +| # | Chunk (phase) | Tasks | Outcome | Commit | Notes flagged upward | +|---|---------------|-------|---------|--------|----------------------| +| 1 | Setup | T001–T002 | 2 ✅ | `5b9622599` | Changelog `added` category; test skeletons collect cleanly. | +| 2 | Foundational | T003–T006 | 4 ✅ | `0805c3658` | Helper named `safe_metric` (PEP 695 generic); `node_count` widened to `dict[str,int\|None]`; placeholder `accounts`/`activity_24h` left at call site for later wiring. | +| 3 | US1 activity_24h enabler | T007–T014 (+T009b) | 9 ✅ | `2656de3c8` | **Prefect is 3.7.5** (not 2026.05 as AGENTS.md claims). **`TIMEDOUT` is not a real StateType** → failure = `FAILED`+`CRASHED`. Flow-run `start_time` not client-settable. `freezegun` not installed → window helper takes explicit `now`; `safe_metric` moved to `utils.py` (import-cycle) and re-exported. | +| 4 | US2 accounts/branches | T015–T018 | 4 ✅ | `5931d35a0` | Status filter `status__value="active"`. Subagent omitted its evidence block; orchestrator **independently re-ran** the tests to capture it (below). | +| 5 | US3 corenode | T019–T021 | 3 ✅ | `6dde986c2` | Independent oracle = raw label count (distinct path from `NodeManager.count`); TDD red `KeyError: 'corenode'` → green. | +| 6 | US5 checks/artifacts/branches | T022–T023 | 2 ✅ | `f5f839f38` | Real event-name constants; removed 8 now-stale `None` assertions from the US1 test. | +| 7 | US4 resilience | T024–T026 | 3 ✅ | `b1da82dbf` | No-mock seam = 3 optional injected async callables with prod defaults; T026 audit wrapped orchestrator-level assembler calls in `safe_metric`; caught a real cross-test flake + a `registry.id` teardown leak via self-review. | +| 8 | Polish + review fix | T027–T030 | 3 ✅, 1 open | `829197d1a` | Suite green, lint clean, local simulation done. **Review fix applied** (webhook count — see §5). T030 governance gate open. | + +## 3. Tasks not completed + +- **T030 — Governance gate (GR-001)**: confirm the cloud-processor + data-mart owners tolerate the `payload_format` bump, ignore unknown fields, and tolerate `null` values (incl. `corenode` in `node_count`). This is an explicit **process/external** task, not code; it must be done before merge/release. No code or test depends on it. + +All implementation tasks T001–T029 are `[X]`. + +## 4. Local-pass evidence + +All tests run with `DOCKER_HOST=unix:///Users/Dimitris/.docker/run/docker.sock` (component tests use testcontainers Neo4j + ephemeral Prefect; unit tests need neither). Authoritative post-everything run: **`62 passed`** at `2026-06-29T08:25:08Z` (`uv run pytest backend/tests/unit/telemetry backend/tests/component/telemetry -q`). + +| Test id | Type | Run command | Passed at (ISO 8601) | Env | Verbatim pass line | +|---------|------|-------------|----------------------|-----|--------------------| +| `test_degradation.py::test_raising_coroutine_degrades_to_none`, `::test_zero_result_is_preserved`, `::test_non_zero_result_is_preserved` | unit | `uv run pytest backend/tests/unit/telemetry/test_degradation.py -v` | 2026-06-28T21:36:07Z | n/a (no DB/mock; plain coroutines) | `3 passed, 16 warnings in 0.11s` | +| `test_task_manager.py::test_window_is_previous_full_utc_day`, `::test_floor_to_midnight_utc`, `::test_windowed_logins_count`, `::test_windowed_unique_logins_count`, `::test_windowed_logins_exclude_out_of_window`, `::test_webhook_success_failure_split`, `::test_webhook_split_excludes_out_of_window`, `::test_gather_activity_24h_logins`, `::test_gather_prefect_events_unchanged` | component | `uv run pytest backend/tests/component/telemetry/test_task_manager.py -v -p no:randomly` | 2026-06-28T21:53:06Z | testcontainers Neo4j + prefect_test_fixture | `10 passed, 16 warnings in 10.95s` (incl. `test_gather_prefect_information`) | +| `test_task_manager.py::test_gather_activity_24h_checks_artifacts_branches[checks_started\|checks_passed\|checks_failed\|artifacts_created\|artifacts_updated\|branches_created\|branches_merged\|branches_deleted]` | component | `uv run pytest backend/tests/component/telemetry/test_task_manager.py -v -p no:randomly` | 2026-06-29T00:00:00Z | testcontainers Neo4j + prefect_test_fixture | `18 passed, 16 warnings in 12.01s` | +| `test_tasks.py::test_gather_account_information_counts`, `::test_active_branches_excludes_default_and_global` | component | `uv run pytest backend/tests/component/telemetry/test_tasks.py -v -p no:randomly` | 2026-06-28T22:05:50Z (orchestrator-captured) | testcontainers Neo4j + registry | `2 passed, 16 warnings in 21.04s` | +| `test_tasks.py::test_gather_full_payload_fields_present`, `::test_gather_genuine_empty_activity_is_zero`, `::test_gather_one_source_fails_others_populated_and_stored`, `::test_gather_activity_source_fails_only_activity_null`, `::test_gather_branch_source_fails_only_branch_active_null` | component | `uv run pytest backend/tests/component/telemetry/test_tasks.py -v -p no:randomly` | 2026-06-29T08:10:25Z | testcontainers Neo4j + MemoryCache + BusSimulator | `7 passed, 16 warnings in 53.54s` | +| `test_datatabase.py::test_gather_database_information_corenode_matches_seeded` | component | `uv run pytest backend/tests/component/telemetry/test_datatabase.py -v -p no:randomly` | 2026-06-28T22:10:20Z | testcontainers Neo4j (2026.05.0-enterprise) | `4 passed, 16 warnings in 22.63s` | + +No E2E tests are part of this feature (producer-only backend; no UI). No row is `MISSING`. + +## 5. Review findings + +| Severity | File | Finding | Disposition | +|----------|------|---------|-------------| +| 🔴 Medium-High | `telemetry/task_manager.py` | `count_webhook_runs` used `len(read_flow_runs(...))`, which caps at the Prefect server default page size (`PREFECT_API_DEFAULT_LIMIT = 200`) — a deployment with >200 webhook successes/day would silently report exactly 200. | **Fixed inline** in `829197d1a` — switched to `client.count_flow_runs(...)` (exact, unpaginated). Webhook tests + full suite re-run green. | +| 🟡 Low (verify) | `telemetry/task_manager.py` | `count_windowed_unique_resources` returns `len(buckets)` from `/events/count-by/resource`; if that endpoint caps the number of buckets returned, `unique_logins` could undercount on very-high-cardinality days. (count-by/**event** is safe — it returns a server-side aggregate `count`, not a list length.) | **Deferred** — lower confidence, and the metric is an explicit best-effort trend signal. Worth confirming the count-by/resource bucket limit before relying on `unique_logins` at scale. | +| 🟡 Low (doc) | spec/contract/data-model/research | Docs list webhook failure as `FAILED`/`CRASHED`/`TIMEDOUT`, but `TIMEDOUT` is not a Prefect 3.7.5 `StateType`; the code correctly uses `FAILED`+`CRASHED`. | **Deferred doc fix** — implementation is correct; the design docs should drop `TIMEDOUT` to match reality. | +| 🔵 Observation | `telemetry/database.py` (corenode) | Live simulation showed `corenode` excludes account **groups** (`get_labels()` doesn't apply the `CoreNode` label to group-generic nodes). This is faithful to the documented "CoreNode-generic" definition, not a bug — but if the product wants groups counted as "managed nodes," that is a definitional choice tied to the parked `user` metric (IFC-2825). | **No change** — surfaced for product awareness. | + +## 6. Autonomous decisions + +- **Dirty-tree handling**: the working tree had many pre-existing unrelated changes. With user approval, every subagent committed by **explicit path only** (never `git add -A`); verified after every chunk that the feature diff stayed within `telemetry/**`, `tests/**/telemetry/**`, `changelog/`, and the spec dir. No unrelated file was committed. +- **Chunking**: one chunk per `tasks.md` phase (8 chunks); US1 kept as a single 9-task chunk (tightly coupled windowed-path work in 3 files). +- **Chunk-4 evidence gap**: that subagent omitted its mandatory evidence block; rather than accept the claim, the orchestrator re-ran the two tests itself to capture verbatim evidence. +- **No-mock resilience seam (US4)**: accepted the optional-injection compromise (3 injected callables with prod defaults) over a larger DI refactor, per the backend-component-design "existing code" exception. +- **Review fix applied inline** (webhook count) rather than deferred, given it's a real accuracy bug with a small localized fix. +- **`speckit-review-run` / `speckit-critique-run` not installed** → review performed directly on the committed diff. +- **Simulation** delivered as a removable demo (kept in scratchpad, not committed) — it hardcodes a scratchpad path and would add ~46s to the component suite, so it does not belong in the committed test set. + +## 7. Suggested next steps + +1. **Resolve the governance gate (T030)** — confirm with the cloud-processor + data-mart owners (the one open item; gates merge/release). +2. **Decide the two deferred review findings**: (a) verify the count-by/resource bucket limit for `unique_logins` at scale; (b) drop `TIMEDOUT` from the design docs to match the implementation. +3. **Open a PR** from `telemetry-collection-infp-589` once T030 is confirmed. +4. Optionally fold the local-collection simulation into a committed smoke test if a repeatable end-to-end demo is wanted (parameterize the scratchpad path first). From f947e6b303573092e52a073dd8abe444d7a7a653 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Wed, 1 Jul 2026 14:21:26 +0200 Subject: [PATCH 20/75] feat(telemetry): add user-defined-namespace node count to database metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add database.node_count["user"]: the count of concrete nodes living in user-defined (non-restricted) namespaces, counted branch/temporal-correctly on the default branch. This is the customer-facing subset — it excludes the Core management namespace, pipeline validators/checks, and by default Builtin kinds. Generics, profiles, templates, and group-generic kinds are skipped so that user stays a strict subset of corenode (groups do not carry the CoreNode label), preserving the user ⊆ corenode ⊆ total nesting invariant. The block is isolated in its own try/except mirroring corenode, so a failure nulls only node_count["user"] while total and the per-graph-label keys remain populated and the payload still ships. Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/telemetry/database.py | 35 ++++++++++++--- .../component/telemetry/test_datatabase.py | 44 +++++++++++++++++++ .../tests/component/telemetry/test_tasks.py | 4 ++ 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/backend/infrahub/telemetry/database.py b/backend/infrahub/telemetry/database.py index 674d69229ec..212fbdfb967 100644 --- a/backend/infrahub/telemetry/database.py +++ b/backend/infrahub/telemetry/database.py @@ -9,6 +9,7 @@ from infrahub.core.graph.schema import GRAPH_SCHEMA from infrahub.core.manager import NodeManager from infrahub.core.query import QueryType +from infrahub.core.schema import NodeSchema from infrahub.database import DatabaseType, InfrahubDatabase from .models import TelemetryDatabaseData, TelemetryDatabaseServerData, TelemetryDatabaseSystemInfoData @@ -67,11 +68,16 @@ async def gather_database_information(db: InfrahubDatabase) -> TelemetryDatabase ``Builtin``, and user-defined namespaces. The ``Core`` management namespace is always non-empty, so ``corenode`` always exceeds the customer-facing subset. Counted through the branch/temporal-correct count path on the default branch, not a raw label tally. - - ``user`` (future, out of scope here) — the customer-facing subset, which excludes the - ``Core`` management namespace. - - ``corenode`` is isolated: if its source raises, only that key is set to ``None`` while - ``total`` and the per-graph-label keys are still populated and the payload still ships. + - ``user`` — the customer-facing subset: concrete nodes living in user-defined (non-restricted) + namespaces, so it excludes the ``Core`` management namespace, the pipeline validators and + checks, and by default ``Builtin`` kinds. Generics, profiles, and templates are not counted, + and group-generic kinds are skipped so that ``user`` stays a subset of ``corenode`` (groups + do not carry the ``CoreNode`` label). Counted through the same branch/temporal-correct count + path on the default branch. + + ``corenode`` and ``user`` are each isolated: if either source raises, only that one key is set + to ``None`` while ``total`` and the per-graph-label keys are still populated and the payload + still ships. """ async with db.start_session(read_only=True) as dbs: server_info = [] @@ -120,4 +126,23 @@ async def gather_database_information(db: InfrahubDatabase) -> TelemetryDatabase log.warning("Telemetry metric collection failed; reporting null for this field: %s", exc) data.node_count["corenode"] = None + # Customer-facing node count: concrete nodes in user-defined (non-restricted) namespaces, + # counted branch/temporal-correctly on the default branch. Group-generic kinds are skipped + # because groups do not carry the CoreNode label, keeping user a subset of corenode. + # Isolated in its own try/except so a failure nulls only this key. + try: + default_branch = registry.get_branch_from_registry() + schema_branch = registry.schema.get_schema_branch(name=default_branch.name) + user_namespaces = [ + namespace.name for namespace in schema_branch.get_namespaces() if namespace.user_editable + ] + user_total = 0 + for node_schema in schema_branch.get_schemas_for_namespaces(namespaces=user_namespaces): + if isinstance(node_schema, NodeSchema) and InfrahubKind.GENERICGROUP not in node_schema.inherit_from: + user_total += await NodeManager.count(db=dbs, schema=node_schema.kind, branch=default_branch) + data.node_count["user"] = user_total + except Exception as exc: + log.warning("Telemetry metric collection failed; reporting null for this field: %s", exc) + data.node_count["user"] = None + return data diff --git a/backend/tests/component/telemetry/test_datatabase.py b/backend/tests/component/telemetry/test_datatabase.py index 83936b9226b..34f84711933 100644 --- a/backend/tests/component/telemetry/test_datatabase.py +++ b/backend/tests/component/telemetry/test_datatabase.py @@ -54,3 +54,47 @@ async def test_gather_database_information_corenode_matches_seeded( # Raw vertex total stays as-is and always contains at least the managed-node subset. assert data.node_count["total"] == await utils.count_nodes(db=db) assert data.node_count["total"] >= data.node_count["corenode"] + + +async def test_gather_database_information_user_counts_only_user_namespaces( + db: InfrahubDatabase, + default_branch: Branch, + register_core_models_schema: SchemaBranch, + car_person_schema: SchemaBranch, +) -> None: + """``node_count["user"]`` counts only nodes in user-defined namespaces, excluding Core. + + The ``Test`` namespace is user-editable, so seeded ``TestPerson`` nodes are user nodes; a + ``CoreAccount`` sits in the restricted ``Core`` management namespace and must not be counted + by ``user`` even though it is a managed ``CoreNode``. The three node metrics nest strictly — + ``user`` ⊆ ``corenode`` ⊆ ``total`` — and the presence of the Core account forces ``user`` + to be strictly below ``corenode``, proving Core management is excluded from the user subset. + + The Core models are registered alongside the user-defined ``Test`` schema so a real + ``CoreAccount`` can be created; every Core kind sits in a restricted namespace, so the + user count stays a clean, exact tally of the seeded ``Test`` nodes. + """ + # With only the user-editable Test namespace registered and no user nodes yet, the gather + # reports zero user nodes — the independent baseline the seeded count is measured against. + baseline = await gather_database_information.fn(db) + assert baseline.node_count["user"] == 0 + + seeded_users = 4 + for index in range(seeded_users): + person = await Node.init(db=db, schema="TestPerson", branch=default_branch) + await person.new(db=db, name=f"user-person-{index}") + await person.save(db=db) + + # A single Core management node: a CoreNode that lives outside every user-editable namespace. + account = await Node.init(db=db, schema=InfrahubKind.ACCOUNT, branch=default_branch) + await account.new(db=db, name="core-account", account_type="User", password="accountPassword123") + await account.save(db=db) + + data = await gather_database_information.fn(db) + + # Exactly the seeded user nodes are counted; the Core account is not. + assert data.node_count["user"] == seeded_users + # Strict nesting: user ⊆ corenode ⊆ total. + assert data.node_count["user"] <= data.node_count["corenode"] <= data.node_count["total"] + # The Core account is a managed node excluded from user, so user is strictly below corenode. + assert data.node_count["user"] < data.node_count["corenode"] diff --git a/backend/tests/component/telemetry/test_tasks.py b/backend/tests/component/telemetry/test_tasks.py index 493a4c190f9..2c612749500 100644 --- a/backend/tests/component/telemetry/test_tasks.py +++ b/backend/tests/component/telemetry/test_tasks.py @@ -169,6 +169,10 @@ async def test_gather_full_payload_fields_present(telemetry_environment: Infrahu assert "corenode" in data.database.node_count assert data.database.node_count["corenode"] is not None + # User-defined-namespace node count is present inside node_count. + assert "user" in data.database.node_count + assert data.database.node_count["user"] is not None + # Every activity_24h field is present (an empty window is 0, not null). for field in _ACTIVITY_FIELDS: assert getattr(data.activity_24h, field) is not None, field From 6de9f6947bf34f12c4d8abd64dced6018017e58c Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Wed, 1 Jul 2026 14:22:34 +0200 Subject: [PATCH 21/75] spec(telemetry): INFP-589 fold node_count.user into Phase 1 scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit user-node count moved from parked (IFC-2825) to in-scope now the namespace boundary is resolved: count user-defined-namespace kinds via the RESTRICTED_NAMESPACES negative filter (excludes Core incl. pipeline validators/checks, and Builtin). Reinstate FR-004, rewrite FR-009 with the corenode pipeline-inflation caveat, update data-model/contract/research/tasks/ changelog. Three node metrics nest user ⊆ corenode ⊆ total. Co-Authored-By: Claude Opus 4.8 --- changelog/+telemetry-phase1.added.md | 2 +- .../contracts/telemetry-payload.md | 28 +++++++----- .../data-model.md | 27 ++++++------ .../telemetry-collection-infp-589/plan.md | 7 +-- .../telemetry-collection-infp-589/research.md | 21 +++++++-- .../telemetry-collection-infp-589/spec.md | 43 ++++++++++++------- .../telemetry-collection-infp-589/tasks.md | 16 ++++--- 7 files changed, 92 insertions(+), 52 deletions(-) diff --git a/changelog/+telemetry-phase1.added.md b/changelog/+telemetry-phase1.added.md index 9e5f3afa214..7daf12a42ea 100644 --- a/changelog/+telemetry-phase1.added.md +++ b/changelog/+telemetry-phase1.added.md @@ -1,4 +1,4 @@ -The daily anonymous telemetry payload now reports additional adoption and activity signals. New fields cover active accounts and account groups (`accounts.active`, `accounts.groups`), the count of open non-system branches (`branches.active`), and a branch- and temporal-correct managed-node count (`database.node_count.corenode`) computed the same way the product counts nodes, distinct from the existing raw vertex total. +The daily anonymous telemetry payload now reports additional adoption and activity signals. New fields cover active accounts and account groups (`accounts.active`, `accounts.groups`), the count of open non-system branches (`branches.active`), and two branch- and temporal-correct node counts computed the same way the product counts nodes, distinct from the existing raw vertex total: `database.node_count.corenode` (all managed nodes) and `database.node_count.user` (user/business nodes in user-defined namespaces, excluding internal and built-in objects). A new `activity_24h` object summarizes what happened over the previous full UTC calendar day: logins and unique logins, validation checks started/passed/failed, artifacts created/updated, branches created/merged/deleted, and webhook deliveries that succeeded or failed. diff --git a/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md b/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md index a297a607bae..9e1ac3e8537 100644 --- a/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md +++ b/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md @@ -38,7 +38,8 @@ data mart) is forward-compatible: it ignores unknown fields, so additive changes // ... "node_count": { "total": 154233, // unchanged: raw vertex total - "corenode": 4821, // NEW key: managed-node count; int | null + "corenode": 4821, // NEW key: all managed nodes; int | null + "user": 3902, // NEW key: user/business nodes (user-defined namespaces); int | null // ... existing graph-label keys unchanged ... } }, @@ -72,6 +73,7 @@ deterministic boundary (not gather-time `now`) so daily snapshots tile exactly. | `accounts.active` | `NodeManager.count(CoreAccount, status=ACTIVE)` | current | `0` | `null` | | `accounts.groups` | `NodeManager.count(CoreAccountGroup)` | current | `0` | `null` | | `database.node_count.corenode` | `NodeManager.count(CoreNode)` | current | `0` | `null` | +| `database.node_count.user` | sum of `NodeManager.count` over user-defined-namespace kinds | current | `0` | `null` | | `activity_24h.logins` | Prefect `account.logged_in` events, windowed | prev. UTC day | `0` | `null` | | `activity_24h.unique_logins` | Prefect count-by-resource on login events, windowed | prev. UTC day | `0` | `null` | | `activity_24h.checks_started` | Prefect `validator.started` events, windowed | prev. UTC day | `0` | `null` | @@ -102,23 +104,26 @@ nesting keys — `user ⊆ corenode ⊆ total`: | Key | Counts | Namespace scope | |-----|--------|-----------------| | `total` | raw vertices | n/a (raw graph) | -| `corenode` | all managed nodes (this phase) | `Core` + `Builtin` + user-defined | -| `user` (future) | customer-facing subset | excludes `Core`; `Builtin` in/out is parked (IFC-2825) | +| `corenode` | all managed nodes; **incl. `Core`-namespace pipeline validators/checks** (so inflatable by proposed-change activity) | `Core` + `Builtin` + user-defined | +| `user` | customer-facing subset | user-defined only (namespace ∉ `RESTRICTED_NAMESPACES`) — excludes `Core` (incl. pipeline validators/checks) and `Builtin` (so `BuiltinTag` uncounted) | `corenode` always includes the `Core` management namespace (always non-empty); `user` never -does — so the two can never coincide. +does — so the two can never coincide. **Consumer caveat:** read `corenode` as a +total-managed-footprint number (it rises and falls with proposed-change pipeline volume), and +`user` as the clean customer-data-scale number. ## Invariants the consumer can rely on 1. **Additive only.** No existing field changes name, type, or meaning. (`node_count` value - type widens to allow `null` only on the new `corenode` key; existing keys stay `int`.) + type widens to allow `null` only on the new `corenode`/`user` keys; existing keys stay `int`.) 2. **`null` means failure, `0` means empty.** A field is `null` iff its source raised during gathering; `0` iff the source succeeded with nothing to count. (FR-010, SC-001) 3. **Payload always ships.** One failing metric never drops the payload; the rest is gathered, stored, and sent. (SC-001) 4. **24h fields are exact to the window.** No leakage from retained-but-out-of-window records. (SC-002) -5. **`corenode` is branch/temporal-correct** and matches an independent fixture count exactly. +5. **`corenode` and `user` are branch/temporal-correct.** `corenode` matches an independent + fixture count exactly; `user` excludes `Core`/`Builtin` nodes, with `user ⊆ corenode ⊆ total`. (SC-003) ## Internal interface contracts (producer side) @@ -128,8 +133,9 @@ These are the new/changed producer-side function contracts (full signatures land - `gather_account_information(db) -> TelemetryAccountData` — both fields via `NodeManager.count`, each degradable to `null`. - `gather_database_information(db) -> TelemetryDatabaseData` — extended to set - `node_count["corenode"]` via `NodeManager.count(CoreNode)`, degradable to `null`, without - touching `node_count["total"]` or the graph-label keys. + `node_count["corenode"]` via `NodeManager.count(CoreNode)` and `node_count["user"]` via the + sum of `NodeManager.count` over user-defined-namespace kinds; each independently degradable to + `null` without touching `node_count["total"]` or the graph-label keys. - `gather_activity_24h(client) -> TelemetryActivity24hData` — windowed login count, windowed unique-login (count-by-resource) count, and `webhook-process` flow-run success/failure split, each field degradable to `null`. @@ -141,6 +147,6 @@ These are the new/changed producer-side function contracts (full signatures land Before shipping: confirm the cloud processor and BigQuery/Metabase data mart (a) tolerate the `payload_format` bump, (b) ignore unknown fields, and (c) tolerate `null` values on the new -fields — including a `null` on the `corenode` key inside `node_count`, the only place a -previously all-integer map can now carry a `null`. Additive design means a forward-compatible -consumer keeps working; this is a release gate, not a code dependency. +fields — including a `null` on the new `corenode`/`user` keys inside `node_count`, the only +place a previously all-integer map can now carry a `null`. Additive design means a +forward-compatible consumer keeps working; this is a release gate, not a code dependency. diff --git a/dev/specs/telemetry-collection-infp-589/data-model.md b/dev/specs/telemetry-collection-infp-589/data-model.md index 80b38eb6daa..738cd8c3e4d 100644 --- a/dev/specs/telemetry-collection-infp-589/data-model.md +++ b/dev/specs/telemetry-collection-infp-589/data-model.md @@ -61,12 +61,13 @@ best-effort daily trend signal). | Key | Type widening | Status | Meaning | |----------------|------------------------------|------------|-------------------------------------------------------------| -| `node_count` | `dict[str, int]` → `dict[str, int \| None]` | **widened** | Holds existing keys (`total`, graph labels) + new `corenode`. | +| `node_count` | `dict[str, int]` → `dict[str, int \| None]` | **widened** | Holds existing keys (`total`, graph labels) + new `corenode`, `user`. | | `…["total"]` | `int` | unchanged | Raw vertex total (`count_nodes(db)`). | | `…["corenode"]`| `int \| None` | **new key**| Managed-node count via `NodeManager.count(CoreNode)`. `0` empty, `null` failure. | +| `…["user"]` | `int \| None` | **new key**| User/business-node count: sum of `NodeManager.count` over node kinds in user-defined (non-restricted) namespaces. `0` empty, `null` failure. | -Widening is additive in practice: existing keys are always populated `int`; only `corenode` -may be `null`. No existing key changes meaning or name (FR-011). +Widening is additive in practice: existing keys are always populated `int`; only `corenode` and +`user` may be `null`. No existing key changes meaning or name (FR-011). **Three node metrics, defined at the namespace level (FR-009).** `CoreNode` is applied to every node outside the `Schema`/`Internal` namespaces (and non-groups), so the three nest strictly — @@ -75,13 +76,15 @@ node outside the `Schema`/`Internal` namespaces (and non-groups), so the three n | Key | Counts | Namespace scope | |-----|--------|-----------------| | `total` | raw vertices (incl. attributes/values/internal bookkeeping) | n/a (raw graph) | -| `corenode` | all managed nodes (this phase) | `Core` + `Builtin` + user-defined | -| `user` (future, IFC-2825) | customer-facing subset | excludes `Core` management namespace; `Builtin` in/out is the parked decision | +| `corenode` | all managed nodes; **incl. `Core`-namespace pipeline validators/checks**, so it can be inflated by proposed-change activity | `Core` + `Builtin` + user-defined | +| `user` | customer-facing subset | user-defined namespaces only (namespace ∉ `RESTRICTED_NAMESPACES`) — excludes `Core` (incl. pipeline validators/checks) and `Builtin` (so `BuiltinTag` is not counted) | -Because the `Core` management namespace (accounts, repositories, proposed changes, webhooks, -profiles, …) is always non-empty and is always in `corenode` but never in `user`, the two can -never collapse into the same value. Pinning these definitions here prevents a later `user` -definition from becoming a synonym for `corenode` (which FR-011 would then make unremovable). +`user` is computed as the sum of `NodeManager.count` over concrete node kinds whose namespace is +user-editable (`namespace not in RESTRICTED_NAMESPACES`), on the default branch — the negative +filter Patrick specified. Group-generic kinds are excluded (they don't carry the `CoreNode` +label), preserving `user ⊆ corenode`. Because the `Core` management namespace is always +non-empty and always in `corenode` but never in `user`, the two can never collapse into the same +value. ### `TelemetryData` (root, extended) @@ -104,11 +107,11 @@ a whole-source failure surfaces as nulled fields, never a missing object (SC-001 (FR-010, SC-001). - **Windowing**: event/flow-run metrics count only records whose occurrence/start is within the trailing 24h (SC-002). -- **Branch-correct**: `corenode`, `accounts.*` computed on the default branch via +- **Branch-correct**: `corenode`, `user`, `accounts.*` computed on the default branch via `NodeManager.count` (Constitution II); `corenode` must equal an independently-computed - fixture exactly (SC-003). + fixture exactly, and `user` must exclude seeded `Core` nodes with `user ⊆ corenode ⊆ total` + (SC-003). ## Out of model (this phase) -- `database.node_count["user"]` — blocked (IFC-2825), not added. - No changes to `TelemetryPrefectData.events` (the existing unwindowed tally). diff --git a/dev/specs/telemetry-collection-infp-589/plan.md b/dev/specs/telemetry-collection-infp-589/plan.md index 6de17767c3f..6734eb47763 100644 --- a/dev/specs/telemetry-collection-infp-589/plan.md +++ b/dev/specs/telemetry-collection-infp-589/plan.md @@ -122,6 +122,7 @@ the current separation and adds no new top-level structure (Constitution VII). data-mart owners that the `payload_format` bump + new fields are tolerated (consumer ignores unknown fields). This is a release checklist item carried into `tasks.md`, not a code dependency — every change is additive. -- **Parked**: `database.node_count.user` (IFC-2825) is out of scope; the contract doc notes - the three-way distinction (`total` raw / `corenode` managed / `user` future) without - implementing `user`. +- **`node_count.user` (IFC-2825) in scope**: pulled into Phase 1 once the namespace boundary + was resolved (count user-defined-namespace kinds via the `RESTRICTED_NAMESPACES` negative + filter — excludes `Core` incl. pipeline validators/checks, and `Builtin`). Delivered + alongside `corenode`; the three node metrics nest `user ⊆ corenode ⊆ total`. diff --git a/dev/specs/telemetry-collection-infp-589/research.md b/dev/specs/telemetry-collection-infp-589/research.md index 0462eda1843..c8f0596c35a 100644 --- a/dev/specs/telemetry-collection-infp-589/research.md +++ b/dev/specs/telemetry-collection-infp-589/research.md @@ -31,9 +31,24 @@ user ⊆ corenode ⊆ total - `total` — raw vertices (incl. attributes/values/internal bookkeeping). - `corenode` — **all** managed nodes across `Core` + `Builtin` + user namespaces (this phase). -- `user` (future, IFC-2825) — customer-facing subset that **excludes the `Core` management - namespace**; the parked decision only chooses whether `Builtin` is in or out, i.e. it slides - the `user` ⊂ `corenode` gap but never closes it (the `Core` namespace is always non-empty). + Note this includes the `Core`-namespace **pipeline validators/checks** created per proposed + change, so `corenode` can be inflated by pipeline activity — a total-managed-footprint number. +- `user` — customer-facing subset in **user-defined namespaces only** (this phase; see decision + below). Excludes `Core` (incl. the pipeline validators/checks) and `Builtin`. + +**`user` definition (decided per Patrick's guidance; IFC-2825 resolved).** Compute `user` as the +sum of `NodeManager.count` over the concrete node kinds whose namespace is **user-editable**, +i.e. `namespace not in RESTRICTED_NAMESPACES` (`Account`, `Branch`, `Builtin`, `Core`, +`Deprecated`, `Diff`, `Infrahub`, `Internal`, `Lineage`, `Schema`, `Profile`, `Template`). The +schema branch already exposes this: `SchemaNamespace.user_editable == (namespace not in +RESTRICTED_NAMESPACES)`, so `get_namespaces()` + `get_schemas_for_namespaces()` yield the +user-defined kinds without re-deriving the filter. This negative filter naturally drops the +`Core`-namespace pipeline validators/checks (the high-volume ephemeral nodes Patrick flagged as +noise) and, being restricted, `Builtin` — so `BuiltinTag` is **not** counted (matches current +product direction; revisit if tags move to user-defined schemas). Group-generic kinds are +excluded (no `CoreNode` label), preserving `user ⊆ corenode`. Rationale for summing per-kind +(vs a single query): `NodeManager.count` is the branch/temporal-correct path (Constitution II); +the number of user kinds is small and this runs once daily. This pins the definitions at the namespace level so a later `user` definition cannot accidentally equal `corenode` — a real concern given FR-011 forbids removing a shipped field. diff --git a/dev/specs/telemetry-collection-infp-589/spec.md b/dev/specs/telemetry-collection-infp-589/spec.md index 7a27437f577..855745623f7 100644 --- a/dev/specs/telemetry-collection-infp-589/spec.md +++ b/dev/specs/telemetry-collection-infp-589/spec.md @@ -25,8 +25,7 @@ separate, downstream effort. The work is deliberately scoped as Phase 1 of a larger telemetry roadmap (epic IFC-2789). Phase 2 metrics (licensing, token usage, generator/transformation adoption, branch lifetime, PR governance, CLI/MCP/Sync adoption, GraphQL/REST -metrics) and the blocked `user_node_count` metric are explicitly **not** in -scope here. +metrics) are explicitly **not** in scope here. ## User Scenarios & Testing *(mandatory)* @@ -224,6 +223,12 @@ in-window events. - **FR-003**: The payload MUST report `database.node_count.corenode` as the count of managed (`CoreNode`-generic) nodes, computed through the branch-safe, temporal-correct count path (not a raw vertex/label count). [IFC-2821] +- **FR-004**: The payload MUST report `database.node_count.user` as the count of + user/business nodes — nodes in user-defined (non-restricted) namespaces — + computed through the branch-safe count path. This is the customer-facing subset + of `corenode`: it excludes the `Core` management namespace (accounts, + repositories, proposed changes, and the pipeline-generated validators/checks) and, + by the same restricted-namespace rule, the `Builtin` namespace. [IFC-2825] - **FR-005**: The payload MUST report `branches.active` as the count of open, non-system branches, excluding the default branch and the global system branch. [IFC-2822] @@ -246,14 +251,19 @@ in-window events. the branch-safe `NodeManager.count` path (same as FR-003). By construction this is every schema-managed node whose namespace is not internal-only (`Schema`/`Internal`); it therefore includes management kinds such as - `CoreAccount`, not just user-defined data. - - `user` (future / blocked — IFC-2825, not in this feature) — a narrower, - customer-facing subset of `corenode`. Its exact namespace boundary — in - particular whether the `Builtin` namespace (tags, IPAM addresses/prefixes) counts - as user data — is an open product decision and MUST NOT be assumed here. - - The metrics nest by construction (`user ⊆ corenode ⊆ total`), but only `total` and - `corenode` are defined and delivered in this phase. [IFC-2821] + `CoreAccount` **and the pipeline-generated validators/checks created per proposed + change**, not just user-defined data. Because of the latter, `corenode` can be + inflated by proposed-change pipeline activity — it is a total-managed-footprint + number, not a clean customer-data number (that is what `user` is for). + - `user` — the customer-facing subset of `corenode`: nodes in user-defined + (non-restricted) namespaces. Defined operationally as the sum over node kinds + whose namespace is NOT in the restricted-namespace set, which excludes the `Core` + management namespace (incl. the pipeline validators/checks) and the `Builtin` + namespace. `BuiltinTag` is therefore not counted (it is `Builtin`); this matches + current product direction and is revisited if tags move to user-defined schemas. + + The metrics nest by construction (`user ⊆ corenode ⊆ total`); all three are + defined and delivered in this phase. [IFC-2821, IFC-2825] - **FR-010**: When a metric's source fails, that field MUST be set to `null` while the rest of the payload is still emitted and stored; when a source succeeds with nothing to count, the field MUST be `0`, not `null`. [IFC-2820] @@ -298,9 +308,10 @@ in-window events. `webhooks_fired_failure`. - **Node-count metrics (`database.node_count`)**: A map of node counts. `total` = raw vertices (existing, unchanged); `corenode` = `CoreNode`-generic count via the - branch-safe count path (new, this phase). A future `user` subset is blocked - (IFC-2825) and not added here. The map may carry a `null` only on the new - `corenode` key (per FR-010 / GR-001). + branch-safe count path (new); `user` = the customer-facing subset in user-defined + (non-restricted) namespaces, via the branch-safe count path (new). They nest + `user ⊆ corenode ⊆ total`. The map may carry a `null` on either new key + (`corenode`, `user`) per FR-010 / GR-001; existing keys are always populated. - **Account**: A user account with a status (active vs. non-active) used for `accounts.active`. - **Account group**: A grouping of accounts, counted for `accounts.groups`. @@ -326,7 +337,9 @@ in-window events. but outside the window. Verified with fixtures containing both in-window and out-of-window records. - **SC-003**: `database.node_count.corenode` matches an independently-computed - fixture count exactly (±0). + fixture count exactly (±0); `database.node_count.user` counts only user-defined + namespace nodes (a seeded `Core` node is excluded), and the nesting invariant + `user ⊆ corenode ⊆ total` holds. - **SC-004**: No existing telemetry field changes meaning, type, or name across this release (the `node_count` map may carry a `null` value only on the new `corenode` key); the `payload_format` identifier is advanced and a @@ -356,8 +369,6 @@ in-window events. ## Out of Scope -- `database.node_count.user` (FR-004 / IFC-2825) — blocked on a product decision - about which namespaces to include; explicitly excluded from this feature. - Remaining Phase 2 telemetry items: licensing (cores/RAM), distinct API-token usage, generators/transformations adoption, branch lifetime, PR governance, CLI/MCP/Sync adoption, and GraphQL/REST metrics. (Checks and artifacts, formerly diff --git a/dev/specs/telemetry-collection-infp-589/tasks.md b/dev/specs/telemetry-collection-infp-589/tasks.md index 26d9d442118..e8c0d8b3a18 100644 --- a/dev/specs/telemetry-collection-infp-589/tasks.md +++ b/dev/specs/telemetry-collection-infp-589/tasks.md @@ -114,13 +114,15 @@ open/system branches; assert each reported count matches the fixture exactly. --- -## Phase 5: User Story 3 — Branch-correct managed-node count (Priority: P2) +## Phase 5: User Story 3 — Branch-correct node counts: `corenode` + `user` (Priority: P2) -**Goal**: Emit `database.node_count.corenode` via the branch/temporal-correct count path, +**Goal**: Emit `database.node_count.corenode` (all managed nodes) and `database.node_count.user` +(user/business nodes in user-defined namespaces) via the branch/temporal-correct count path, leaving `node_count.total` (raw vertices) unchanged. **Independent Test**: Seed a known number of managed nodes, independently compute the expected -count, and assert `node_count["corenode"]` matches exactly (±0). +count, and assert `node_count["corenode"]` matches exactly (±0); seed user-defined + `Core` nodes +and assert `node_count["user"]` counts only the user-defined ones with `user ⊆ corenode ⊆ total`. ### Tests for User Story 3 (write first, must fail) ⚠️ @@ -129,9 +131,11 @@ count, and assert `node_count["corenode"]` matches exactly (±0). ### Implementation for User Story 3 - [x] T020 [US3] In `backend/infrahub/telemetry/database.py`, set `node_count["corenode"]` via `NodeManager.count(db, schema=InfrahubKind.NODE, branch=)`, wrapped so a failure sets `corenode=None` without affecting `node_count["total"]` or the existing graph-label keys (do NOT use raw `count_nodes(label=...)`). -- [x] T021 [US3] Add/extend a docstring or module note distinguishing the three node metrics at the namespace level: `total` (raw vertices), `corenode` (all managed nodes — `Core` + `Builtin` + user-defined namespaces), and the future/out-of-scope `user` (customer-facing subset excluding the `Core` management namespace), noting they nest `user ⊆ corenode ⊆ total`. No tickets/IDs in source. +- [x] T021 [US3] Add/extend a docstring or module note distinguishing the three node metrics at the namespace level: `total` (raw vertices), `corenode` (all managed nodes — `Core` + `Builtin` + user-defined namespaces), and `user` (customer-facing subset excluding the `Core` management namespace), noting they nest `user ⊆ corenode ⊆ total`. No tickets/IDs in source. +- [ ] T021b [US3] Component test in `backend/tests/component/telemetry/test_datatabase.py`: seed user-defined nodes (`Test` namespace via `car_person_schema`) + at least one `Core` node (a `CoreAccount`); assert `node_count["user"]` equals the user-defined count exactly (Core node excluded) and `user <= corenode <= total`, with `user < corenode` when a Core node exists. +- [ ] T021c [US3] In `backend/infrahub/telemetry/database.py`, set `node_count["user"]` = sum of `NodeManager.count` over concrete node kinds in user-editable namespaces (`SchemaNamespace.user_editable`, i.e. `namespace not in RESTRICTED_NAMESPACES`), excluding group-generic kinds; wrapped so a failure sets only `user=None`. Update `test_tasks.py` full-payload presence test to assert `node_count["user"]` is present. -**Checkpoint**: `corenode` exact and branch-correct; raw `total` preserved. +**Checkpoint**: `corenode` + `user` exact and branch-correct; `user` excludes `Core`/`Builtin`; raw `total` preserved. --- @@ -266,7 +270,7 @@ Task: "Regression test gather_prefect_events unchanged" - US5 (checks/artifacts/branch-lifecycle counts) is a user-directed pull-in from Phase 2 — cheap because the events already flow; see `alignment-check.md` §6 for the sanctioned-scope-expansion record. -- Out of scope (do not implement): `database.node_count.user` (blocked); branch *lifetime* +- Out of scope (do not implement): branch *lifetime* (duration — needs correlation); PR "merged-without-review" (needs correlation); node churn (`node.*` — machine-dominated, noisy); branch `rebased`/`migrated` counts (low-signal); remaining Phase 2 metrics (generators/transforms, tokens, CLI/MCP/Sync, licensing); From 9495bcd789454619cd7539136d91d376f242287c Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Thu, 2 Jul 2026 09:52:30 +0200 Subject: [PATCH 22/75] fix(telemetry): make activity windows half-open at the upper boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prefect's EventOccurredFilter is inclusive on both since and until, and the flow-run start_time.before_ filter is 'at or before' — so passing until=window_end counted an event stamped exactly at midnight in two consecutive daily windows. Found via a live multi-day tiling simulation (4 windows over 12 seeded events summed to 14). Pull the upper bound back one microsecond on both windowed paths so the effective interval is the documented [start, end); tiling now sums exactly. Regression: seeded_logins now places events at exactly window_start (counted) and exactly window_end (excluded, belongs to the next day). Suite: 63 passed. Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/telemetry/task_manager.py | 19 +++++++++++---- .../component/telemetry/test_task_manager.py | 23 ++++++++++++------- .../telemetry-collection-infp-589/research.md | 9 ++++++++ 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/backend/infrahub/telemetry/task_manager.py b/backend/infrahub/telemetry/task_manager.py index 9ed220a5dd1..6df93543bc5 100644 --- a/backend/infrahub/telemetry/task_manager.py +++ b/backend/infrahub/telemetry/task_manager.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta from typing import Any from prefect import task @@ -72,11 +72,18 @@ async def count_events(event_name: str) -> int: def _windowed_event_filter(event_name: str, window_start: datetime, window_end: datetime) -> dict[str, Any]: - """Build the count-by request body: an event-name filter narrowed to ``[start, end)``.""" + """Build the count-by request body: an event-name filter narrowed to ``[start, end)``. + + Prefect's ``EventOccurredFilter`` treats both ``since`` and ``until`` as inclusive + (``since <= occurred <= until``). The window contract is half-open, so ``until`` is pulled + back by one microsecond — otherwise an event stamped exactly on the boundary would be + counted in two consecutive windows. + """ + until = window_end - timedelta(microseconds=1) return { "filter": { "event": {"name": [event_name]}, - "occurred": {"since": window_start.isoformat(), "until": window_end.isoformat()}, + "occurred": {"since": window_start.isoformat(), "until": until.isoformat()}, } } @@ -123,9 +130,11 @@ async def count_webhook_runs(client: PrefectClient, window_start: datetime, wind """ flow_filter = FlowFilter(name=FlowFilterName(any_=[WEBHOOK_FLOW_NAME])) # The flow-run start-time filter is typed with Prefect's own datetime; coerce the stdlib - # boundaries to it so the comparison stays well-typed. + # boundaries to it so the comparison stays well-typed. ``before_`` is inclusive ("at or + # before"), so pull it back one microsecond to keep the window half-open — a run starting + # exactly on the boundary must land in exactly one window. after = DateTime.fromisoformat(window_start.isoformat()) - before = DateTime.fromisoformat(window_end.isoformat()) + before = DateTime.fromisoformat((window_end - timedelta(microseconds=1)).isoformat()) def runs_in_states(states: list[StateType]) -> FlowRunFilter: return FlowRunFilter( diff --git a/backend/tests/component/telemetry/test_task_manager.py b/backend/tests/component/telemetry/test_task_manager.py index d68fb7487be..8263d23981e 100644 --- a/backend/tests/component/telemetry/test_task_manager.py +++ b/backend/tests/component/telemetry/test_task_manager.py @@ -83,6 +83,9 @@ async def seeded_logins(prefect_client: PrefectClient) -> str: [window_start, window_end). Events are placed relative to that real boundary: - account ``a``: two logins inside the window (repeat → one unique bucket) - account ``b``: one login inside the window + - account ``atstart``: one login at exactly window_start (included — half-open interval) + - account ``atend``: one login at exactly window_end (excluded — belongs to the next day; + counting it here as well would double-count it across two consecutive daily windows) - account ``before``: one login one minute before window_start (excluded) - account ``after``: one login one minute after window_end (excluded) @@ -99,6 +102,8 @@ async def seeded_logins(prefect_client: PrefectClient) -> str: _login_event(acct_a, in_window), _login_event(acct_a, in_window + timedelta(minutes=5)), _login_event(acct_b, in_window), + _login_event(f"atstart-{suffix}", window_start), + _login_event(f"atend-{suffix}", window_end), _login_event(f"before-{suffix}", window_start - timedelta(minutes=1)), _login_event(f"after-{suffix}", window_end + timedelta(minutes=1)), ] @@ -166,9 +171,10 @@ async def test_windowed_logins_count(prefect_client: PrefectClient, seeded_login window_start=window_start, window_end=window_end, ) - # Three in-window logins (two from account a, one from account b); the before/after - # boundary events are excluded — proving the window is anchored to midnight, not now. - assert count == 3 + # Four in-window logins (two from account a, one from account b, one at exactly + # window_start). The event at exactly window_end and the before/after events are excluded — + # proving the interval is half-open and anchored to midnight, not to now. + assert count == 4 async def test_windowed_unique_logins_count(prefect_client: PrefectClient, seeded_logins: str) -> None: @@ -179,8 +185,8 @@ async def test_windowed_unique_logins_count(prefect_client: PrefectClient, seede window_start=window_start, window_end=window_end, ) - # Two distinct accounts in-window (a, b); account a's repeat login collapses. - assert unique == 2 + # Three distinct accounts in-window (a, b, atstart); account a's repeat login collapses. + assert unique == 3 async def test_windowed_logins_exclude_out_of_window(prefect_client: PrefectClient, seeded_logins: str) -> None: @@ -243,9 +249,10 @@ async def test_webhook_split_excludes_out_of_window(prefect_client: PrefectClien async def test_gather_activity_24h_logins(prefect_client: PrefectClient, seeded_logins: str) -> None: data = await gather_activity_24h.fn(client=prefect_client) - # Login fields reflect exactly the in-window seeded events. - assert data.logins == 3 - assert data.unique_logins == 2 + # Login fields reflect exactly the in-window seeded events (incl. the one at exactly + # window_start; the one at exactly window_end belongs to the next day). + assert data.logins == 4 + assert data.unique_logins == 3 # Webhook fields are present (an empty window is 0, not null). Webhook runs seeded by # other tests are stamped at "today", which is after the previous-day window the gather # computes, so this assertion does not depend on cross-test ordering for a 0. diff --git a/dev/specs/telemetry-collection-infp-589/research.md b/dev/specs/telemetry-collection-infp-589/research.md index c8f0596c35a..4dc23903cba 100644 --- a/dev/specs/telemetry-collection-infp-589/research.md +++ b/dev/specs/telemetry-collection-infp-589/research.md @@ -112,6 +112,15 @@ filter, alongside the existing `event.name` filter. For `account.logged_in`: The existing `gather_prefect_events` (no time window) is **left untouched** (FR-007); the new path is separate functions feeding `activity_24h`. +**Boundary inclusivity (found via live multi-day simulation)**: Prefect's `EventOccurredFilter` +treats both `since` and `until` as **inclusive** (`since <= occurred <= until`), and the +flow-run `start_time.before_` is likewise "at or before". Passing `until = window_end` would +therefore count an event stamped exactly at midnight in **two consecutive daily windows** +(observed: a 4-day tiling simulation summed 14 counts over 12 seeded events). Both windowed +paths pull the upper bound back by one microsecond so the effective interval is the documented +half-open `[start, end)`; an exact-boundary event now lands in exactly one window (regression +test seeds events at exactly `window_start` — counted — and exactly `window_end` — excluded). + **Rationale**: Logins are events stored in Prefect (ADR 0002); Neo4j has no `last_login`. The event name is `infrahub.account.logged_in` (`AccountLoggedInEvent.event_name`). The 24h window (≪ 7-day event retention) guarantees no retention leakage when the `occurred` filter From 97f6fb9756e223699b03d181692ce67ec7c69319 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Fri, 3 Jul 2026 16:24:38 +0200 Subject: [PATCH 23/75] refactor(telemetry): tighten degradation handling, docs, and tests Route the per-metric fallbacks in the database gather through the single safe_metric boundary instead of duplicating broad try/except blocks, so a failing source nulls only its own field. Trim comments and docstrings across the telemetry collection code to the WHY. Add a concept doc for how telemetry works (categories, the three temporal models, windowing vs retention, graceful degradation) and a manual test plan plus an ad-hoc window probe under the spec. Add a unit test documenting the validator-event emission asymmetry that makes checks_started exceed the terminal counts. Co-Authored-By: Claude Opus 4.8 --- backend/AGENTS.md | 1 + backend/infrahub/telemetry/database.py | 90 +++++------- backend/infrahub/telemetry/task_manager.py | 29 ++-- backend/infrahub/telemetry/tasks.py | 29 ++-- backend/infrahub/telemetry/utils.py | 6 +- backend/infrahub/telemetry/window.py | 8 +- .../component/telemetry/test_datatabase.py | 36 ++--- .../tests/unit/core/validators/__init__.py | 0 .../core/validators/test_checks_runner.py | 130 ++++++++++++++++++ dev/README.md | 1 + dev/knowledge/backend/telemetry.md | 114 +++++++++++++++ .../data-model.md | 7 + .../manual-test-runbook.md | 125 +++++++++++++++++ .../window_probe.py | 83 +++++++++++ 14 files changed, 536 insertions(+), 123 deletions(-) create mode 100644 backend/tests/unit/core/validators/__init__.py create mode 100644 backend/tests/unit/core/validators/test_checks_runner.py create mode 100644 dev/knowledge/backend/telemetry.md create mode 100644 dev/specs/telemetry-collection-infp-589/manual-test-runbook.md create mode 100644 dev/specs/telemetry-collection-infp-589/window_probe.py diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 17cc026705f..a8a8bb4e5ce 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -93,6 +93,7 @@ See `dev/knowledge/backend/testing.md` for detailed testing infrastructure docum - `dev/knowledge/backend/events.md` - Events system - `dev/knowledge/backend/async-tasks.md` - Asynchronous tasks (Prefect) - `dev/knowledge/backend/message-bus.md` - Message bus system +- `dev/knowledge/backend/telemetry.md` - Anonymous usage telemetry (categories, windowing, retention, degradation) ### Guides (How to do X) diff --git a/backend/infrahub/telemetry/database.py b/backend/infrahub/telemetry/database.py index 212fbdfb967..d21c8f8eed0 100644 --- a/backend/infrahub/telemetry/database.py +++ b/backend/infrahub/telemetry/database.py @@ -1,5 +1,3 @@ -import logging - from neo4j.exceptions import Neo4jError from prefect import task from prefect.cache_policies import NONE @@ -13,8 +11,7 @@ from infrahub.database import DatabaseType, InfrahubDatabase from .models import TelemetryDatabaseData, TelemetryDatabaseServerData, TelemetryDatabaseSystemInfoData - -log = logging.getLogger(__name__) +from .utils import safe_metric async def get_server_info(db: InfrahubDatabase) -> list[TelemetryDatabaseServerData]: @@ -54,30 +51,34 @@ async def get_system_info(db: InfrahubDatabase) -> TelemetryDatabaseSystemInfoDa ) +async def count_corenode(db: InfrahubDatabase) -> int: + """Count managed nodes via the branch/temporal-correct path, not a raw label tally.""" + return await NodeManager.count(db=db, schema=InfrahubKind.NODE, branch=registry.get_branch_from_registry()) + + +async def count_user_nodes(db: InfrahubDatabase) -> int: + """Count concrete nodes in user-editable namespaces. + + Group-generic kinds are excluded because they do not carry the ``CoreNode`` label, which + would otherwise let ``user`` exceed ``corenode``. + """ + default_branch = registry.get_branch_from_registry() + schema_branch = registry.schema.get_schema_branch(name=default_branch.name) + user_namespaces = [namespace.name for namespace in schema_branch.get_namespaces() if namespace.user_editable] + total = 0 + for node_schema in schema_branch.get_schemas_for_namespaces(namespaces=user_namespaces): + if isinstance(node_schema, NodeSchema) and InfrahubKind.GENERICGROUP not in node_schema.inherit_from: + total += await NodeManager.count(db=db, schema=node_schema.kind, branch=default_branch) + return total + + @task(name="telemetry-gather-db", task_run_name="Gather Database Information", cache_policy=NONE) async def gather_database_information(db: InfrahubDatabase) -> TelemetryDatabaseData: - """Gather database node and relationship counts for the telemetry payload. - - ``node_count`` carries three semantically distinct, strictly nesting node metrics — - ``user`` ⊆ ``corenode`` ⊆ ``total`` — defined at the namespace level so they can never - collapse into synonyms: - - - ``total`` — raw vertex count of the whole graph, including attributes, values, and - internal bookkeeping nodes across all branches and history. - - ``corenode`` — all managed nodes (the ``CoreNode`` generic), spanning the ``Core``, - ``Builtin``, and user-defined namespaces. The ``Core`` management namespace is always - non-empty, so ``corenode`` always exceeds the customer-facing subset. Counted through - the branch/temporal-correct count path on the default branch, not a raw label tally. - - ``user`` — the customer-facing subset: concrete nodes living in user-defined (non-restricted) - namespaces, so it excludes the ``Core`` management namespace, the pipeline validators and - checks, and by default ``Builtin`` kinds. Generics, profiles, and templates are not counted, - and group-generic kinds are skipped so that ``user`` stays a subset of ``corenode`` (groups - do not carry the ``CoreNode`` label). Counted through the same branch/temporal-correct count - path on the default branch. - - ``corenode`` and ``user`` are each isolated: if either source raises, only that one key is set - to ``None`` while ``total`` and the per-graph-label keys are still populated and the payload - still ships. + """Gather node/relationship counts and database server/system info. + + ``node_count`` holds three strictly nesting metrics, ``user`` ⊆ ``corenode`` ⊆ ``total``; + ``corenode`` and ``user`` degrade to ``None`` independently, leaving ``total`` and the + per-graph-label counts intact. """ async with db.start_session(read_only=True) as dbs: server_info = [] @@ -88,8 +89,7 @@ async def gather_database_information(db: InfrahubDatabase) -> TelemetryDatabase server_info = await get_server_info(db=dbs) system_info = await get_system_info(db=dbs) - # server_info is only available on Neo4j Enterprise - # so if it's not empty, we can assume the database is of type Enterprise + # server_info is populated only on Neo4j Enterprise, so a non-empty result implies it. if len(server_info) == 0: database_type = f"{database_type}-community" else: @@ -113,36 +113,8 @@ async def gather_database_information(db: InfrahubDatabase) -> TelemetryDatabase for name in GRAPH_SCHEMA["nodes"]: data.node_count[name] = await utils.count_nodes(db=dbs, label=name) - # Managed-node count via the branch/temporal-correct count path on the default branch. - # Isolated in its own try/except so a failure nulls only this key, leaving the raw - # "total" and the per-graph-label counts intact. - try: - data.node_count["corenode"] = await NodeManager.count( - db=dbs, - schema=InfrahubKind.NODE, - branch=registry.get_branch_from_registry(), - ) - except Exception as exc: - log.warning("Telemetry metric collection failed; reporting null for this field: %s", exc) - data.node_count["corenode"] = None - - # Customer-facing node count: concrete nodes in user-defined (non-restricted) namespaces, - # counted branch/temporal-correctly on the default branch. Group-generic kinds are skipped - # because groups do not carry the CoreNode label, keeping user a subset of corenode. - # Isolated in its own try/except so a failure nulls only this key. - try: - default_branch = registry.get_branch_from_registry() - schema_branch = registry.schema.get_schema_branch(name=default_branch.name) - user_namespaces = [ - namespace.name for namespace in schema_branch.get_namespaces() if namespace.user_editable - ] - user_total = 0 - for node_schema in schema_branch.get_schemas_for_namespaces(namespaces=user_namespaces): - if isinstance(node_schema, NodeSchema) and InfrahubKind.GENERICGROUP not in node_schema.inherit_from: - user_total += await NodeManager.count(db=dbs, schema=node_schema.kind, branch=default_branch) - data.node_count["user"] = user_total - except Exception as exc: - log.warning("Telemetry metric collection failed; reporting null for this field: %s", exc) - data.node_count["user"] = None + # corenode/user each degrade to None independently through the shared metric helper. + data.node_count["corenode"] = await safe_metric(count_corenode(db=dbs)) + data.node_count["user"] = await safe_metric(count_user_nodes(db=dbs)) return data diff --git a/backend/infrahub/telemetry/task_manager.py b/backend/infrahub/telemetry/task_manager.py index 6df93543bc5..78c1e9f4fe2 100644 --- a/backend/infrahub/telemetry/task_manager.py +++ b/backend/infrahub/telemetry/task_manager.py @@ -72,12 +72,10 @@ async def count_events(event_name: str) -> int: def _windowed_event_filter(event_name: str, window_start: datetime, window_end: datetime) -> dict[str, Any]: - """Build the count-by request body: an event-name filter narrowed to ``[start, end)``. + """Build the count-by filter for ``[start, end)``. - Prefect's ``EventOccurredFilter`` treats both ``since`` and ``until`` as inclusive - (``since <= occurred <= until``). The window contract is half-open, so ``until`` is pulled - back by one microsecond — otherwise an event stamped exactly on the boundary would be - counted in two consecutive windows. + Prefect's ``EventOccurredFilter`` is inclusive on both ends, so ``until`` is pulled back a + microsecond to keep the window half-open — else a boundary event counts in two windows. """ until = window_end - timedelta(microseconds=1) return { @@ -106,10 +104,10 @@ async def count_windowed_event( async def count_windowed_unique_resources( client: PrefectClient, event_name: str, window_start: datetime, window_end: datetime ) -> int: - """Count distinct resources emitting one event name within ``[window_start, window_end)``. + """Count distinct resources emitting one event within ``[window_start, window_end)``. - Counting by resource returns one bucket per distinct ``prefect.resource.id``, so the number - of buckets is the distinct-resource total over the window. + Count-by-resource returns one bucket per distinct resource id, so the bucket count is the + distinct total. """ payload = _windowed_event_filter(event_name=event_name, window_start=window_start, window_end=window_end) response = await client._client.post("/events/count-by/resource", json=payload) @@ -124,15 +122,12 @@ async def count_windowed_unique_resources( async def count_webhook_runs(client: PrefectClient, window_start: datetime, window_end: datetime) -> tuple[int, int]: """Return ``(success, failure)`` webhook flow-run counts started within the window. - Success is the count of runs in a terminal ``COMPLETED`` state; failure is the count in a - terminal ``FAILED``/``CRASHED`` state. Runs still in a non-terminal state at gather time are - counted in neither. + Success = terminal ``COMPLETED``; failure = terminal ``FAILED``/``CRASHED``; non-terminal + runs count in neither. """ flow_filter = FlowFilter(name=FlowFilterName(any_=[WEBHOOK_FLOW_NAME])) - # The flow-run start-time filter is typed with Prefect's own datetime; coerce the stdlib - # boundaries to it so the comparison stays well-typed. ``before_`` is inclusive ("at or - # before"), so pull it back one microsecond to keep the window half-open — a run starting - # exactly on the boundary must land in exactly one window. + # ``before_`` is inclusive, so pull the upper bound back a microsecond — a run on the + # boundary must land in exactly one window. after = DateTime.fromisoformat(window_start.isoformat()) before = DateTime.fromisoformat((window_end - timedelta(microseconds=1)).isoformat()) @@ -142,9 +137,7 @@ def runs_in_states(states: list[StateType]) -> FlowRunFilter: state=FlowRunFilterState(type=FlowRunFilterStateType(any_=states)), ) - # Count server-side rather than reading run objects: read_flow_runs caps at the server - # default page size (PREFECT_API_DEFAULT_LIMIT), which would silently undercount a busy - # deployment's daily webhook volume. count_flow_runs returns an exact, unpaginated total. + # Count server-side: read_flow_runs caps at the API page size and would undercount a busy day. success = await client.count_flow_runs( flow_filter=flow_filter, flow_run_filter=runs_in_states([StateType.COMPLETED]) ) diff --git a/backend/infrahub/telemetry/tasks.py b/backend/infrahub/telemetry/tasks.py index 524514b68f0..87bd64b6507 100644 --- a/backend/infrahub/telemetry/tasks.py +++ b/backend/infrahub/telemetry/tasks.py @@ -77,12 +77,10 @@ async def gather_feature_information() -> dict[str, int]: @task(name="telemetry-account-information", task_run_name="Gather Account Information", cache_policy=NONE) async def gather_account_information(db: InfrahubDatabase) -> TelemetryAccountData: - """Gather account adoption counts on the default branch, each degradable to ``null``. + """Gather account adoption counts on the default branch. - ``active`` counts managed accounts whose status is active; ``groups`` counts account - groups. Counts go through the branch/temporal-correct count path so they match what the - GraphQL resolvers report. Either field degrades to ``null`` independently if its source - raises, while a source that simply finds nothing returns ``0``. + Counted through the branch/temporal-correct path so they match the GraphQL resolvers; each + field degrades to ``null`` independently on failure. """ default_branch = registry.get_branch_from_registry() @@ -106,12 +104,10 @@ async def gather_account_information(db: InfrahubDatabase) -> TelemetryAccountDa async def count_active_branches() -> int: - """Count open non-system branches: registry branches excluding the default and global ones. + """Count open non-system branches. - Closed/merged/deleted branches are removed from the registry, so registry membership - already means "open"; the default (``main``) and global (``-global-``) branches are the - only system branches to exclude. Async so it composes with the per-metric degradation - helper alongside the awaitable count sources. + Registry members minus the default and global branches (closed branches are evicted from + the registry). Async only so it composes with the degradation helper. """ return len([branch for branch in registry.branch.values() if not branch.is_default and not branch.is_global]) @@ -153,13 +149,10 @@ async def gather_anonymous_telemetry_data( ) -> TelemetryData: """Assemble the full telemetry payload, isolating every metric source. - Each new metric source is gathered through the degradation helper so a single failing - source degrades only its own field(s) to ``null`` while the rest of the payload is still - built, stored, and sent. A source that succeeds with nothing to count yields ``0``. - - The three ``*_gatherer`` / ``*_counter`` parameters are optional injection seams with - production defaults; they exist so the orchestrator's per-source isolation can be exercised - with a real failing collaborator instead of patching. Production callers pass none of them. + Each source is gathered through the degradation helper, so one failing source nulls only + its own field(s) while the rest is still built. The ``*_gatherer`` / ``*_counter`` params + are injection seams (production passes none) that let the per-source isolation be tested + with a real failing collaborator instead of patching. """ start_time = time.time() @@ -241,7 +234,6 @@ async def send_telemetry_push() -> None: log.warning(f"Failed to store telemetry snapshot locally: {exc}") return - # Conditionally send remotely if config.SETTINGS.main.telemetry_optout: log.info("User opted out of remote telemetry. Marking snapshot as skipped.") snapshot.remote_send_status = RemoteSendStatus.SKIPPED @@ -264,7 +256,6 @@ async def send_telemetry_push() -> None: snapshot.remote_send_status = RemoteSendStatus.FAILED log.warning(f"Failed to send telemetry data to remote endpoint: {exc}") - # Update remote send status in DB try: await repository.save(snapshot) except Exception as exc: diff --git a/backend/infrahub/telemetry/utils.py b/backend/infrahub/telemetry/utils.py index cf1ff766830..533f0e1390f 100644 --- a/backend/infrahub/telemetry/utils.py +++ b/backend/infrahub/telemetry/utils.py @@ -18,10 +18,8 @@ def determine_infrahub_type() -> InfrahubType: async def safe_metric[T](coro: Awaitable[T]) -> T | None: """Run one metric coroutine in isolation, degrading a failure to ``None``. - Returns the awaited result on success — including a falsy value such as - ``0`` (a source that succeeded with nothing to count). On any exception the - failure is logged and ``None`` is returned, so a single broken source nulls - only its own field instead of dropping the whole payload. + A falsy result like ``0`` is preserved (measured, nothing to count); only an exception + yields ``None`` (logged), so one broken source nulls its own field, not the whole payload. """ try: return await coro diff --git a/backend/infrahub/telemetry/window.py b/backend/infrahub/telemetry/window.py index e634acf6d8d..8547992d103 100644 --- a/backend/infrahub/telemetry/window.py +++ b/backend/infrahub/telemetry/window.py @@ -10,12 +10,10 @@ def floor_to_midnight_utc(moment: datetime) -> datetime: def get_activity_window(now: datetime | None = None) -> tuple[datetime, datetime]: - """Return the ``[window_start, window_end)`` covering the previous full UTC calendar day. + """Return the half-open ``[window_start, window_end)`` for the previous full UTC day. - ``window_end`` is midnight UTC of the current day and ``window_start`` is 24h earlier, so - consecutive daily runs tile exactly regardless of when within the day they execute. The - window is anchored to a deterministic calendar boundary, never to the raw ``now`` instant, - which is what keeps a daily series free of overlaps and gaps. + Anchored to the midnight-UTC boundary, not the raw ``now`` instant, so consecutive daily + runs tile exactly — no overlaps or gaps — whatever time the (jittered) cron fires. """ reference = now if now is not None else datetime.now(tz=UTC) window_end = floor_to_midnight_utc(reference) diff --git a/backend/tests/component/telemetry/test_datatabase.py b/backend/tests/component/telemetry/test_datatabase.py index 34f84711933..f0292b378ca 100644 --- a/backend/tests/component/telemetry/test_datatabase.py +++ b/backend/tests/component/telemetry/test_datatabase.py @@ -25,13 +25,10 @@ async def test_gather_database_information(db: InfrahubDatabase) -> None: async def test_gather_database_information_corenode_matches_seeded( db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch ) -> None: - """``node_count["corenode"]`` equals an independently-computed managed-node count exactly. + """``corenode`` matches an independently-computed managed-node count exactly. - ``corenode`` goes through the branch/temporal-correct count path. The seeded - ``TestPerson`` nodes carry the ``CoreNode`` generic label (their namespace is neither - ``Schema`` nor ``Internal``), so an independent raw label count is the oracle: the count - the gather reports must match it to the node, and the raw vertex ``total`` must be left - untouched and never smaller than the managed-node subset. + The oracle is a raw ``CoreNode``-label count — a different code path from the gather's + ``NodeManager.count`` — and the raw ``total`` must stay untouched and never below the subset. """ # Baseline of pre-existing managed nodes via a raw label count — a different code path # from NodeManager.count — so the assertion holds regardless of any nodes already present. @@ -62,17 +59,11 @@ async def test_gather_database_information_user_counts_only_user_namespaces( register_core_models_schema: SchemaBranch, car_person_schema: SchemaBranch, ) -> None: - """``node_count["user"]`` counts only nodes in user-defined namespaces, excluding Core. + """``user`` counts only user-defined-namespace nodes, excluding Core. - The ``Test`` namespace is user-editable, so seeded ``TestPerson`` nodes are user nodes; a - ``CoreAccount`` sits in the restricted ``Core`` management namespace and must not be counted - by ``user`` even though it is a managed ``CoreNode``. The three node metrics nest strictly — - ``user`` ⊆ ``corenode`` ⊆ ``total`` — and the presence of the Core account forces ``user`` - to be strictly below ``corenode``, proving Core management is excluded from the user subset. - - The Core models are registered alongside the user-defined ``Test`` schema so a real - ``CoreAccount`` can be created; every Core kind sits in a restricted namespace, so the - user count stays a clean, exact tally of the seeded ``Test`` nodes. + A seeded ``CoreAccount`` (restricted ``Core`` namespace) is a managed ``CoreNode`` but must + not be counted by ``user`` — forcing ``user < corenode`` and proving Core is excluded, while + ``user`` ⊆ ``corenode`` ⊆ ``total`` still holds. """ # With only the user-editable Test namespace registered and no user nodes yet, the gather # reports zero user nodes — the independent baseline the seeded count is measured against. @@ -94,7 +85,16 @@ async def test_gather_database_information_user_counts_only_user_namespaces( # Exactly the seeded user nodes are counted; the Core account is not. assert data.node_count["user"] == seeded_users + + # All three counts are populated in this scenario; None is only reported on a count fallback. + user_count = data.node_count["user"] + corenode_count = data.node_count["corenode"] + total_count = data.node_count["total"] + assert user_count is not None + assert corenode_count is not None + assert total_count is not None + # Strict nesting: user ⊆ corenode ⊆ total. - assert data.node_count["user"] <= data.node_count["corenode"] <= data.node_count["total"] + assert user_count <= corenode_count <= total_count # The Core account is a managed node excluded from user, so user is strictly below corenode. - assert data.node_count["user"] < data.node_count["corenode"] + assert user_count < corenode_count diff --git a/backend/tests/unit/core/validators/__init__.py b/backend/tests/unit/core/validators/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/backend/tests/unit/core/validators/test_checks_runner.py b/backend/tests/unit/core/validators/test_checks_runner.py new file mode 100644 index 00000000000..b76d271c059 --- /dev/null +++ b/backend/tests/unit/core/validators/test_checks_runner.py @@ -0,0 +1,130 @@ +"""Unit tests for the validator checks-runner's event emission. + +These pin down which validator lifecycle events the checks-runner emits, and — importantly — +which it does not. See the test docstring for why the ``started``/terminal split matters. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, cast + +import pytest + +from infrahub.auth.session import AccountSession +from infrahub.auth.types import AuthType +from infrahub.context import BranchContext, InfrahubContext +from infrahub.core.constants import ValidatorConclusion, ValidatorState +from infrahub.core.validators.checks_runner import run_checks_and_update_validator +from infrahub.events.validator_action import ValidatorFailedEvent, ValidatorPassedEvent, ValidatorStartedEvent +from tests.adapters.event import MemoryInfrahubEvent + +if TYPE_CHECKING: + from collections.abc import Coroutine + + from infrahub_sdk.protocols import CoreValidator + + +@dataclass +class _Attr: + """Mutable stand-in for an SDK node attribute, exposing a single ``value``.""" + + value: object | None = None + + +class FakeValidator: + """Minimal stand-in for an SDK ``CoreValidator``. + + Which events fire depends only on the check results, not on persistence, so a fake with a + no-op ``save`` is enough — no real node or live API needed. + """ + + def __init__(self, *, validator_id: str, kind: str) -> None: + self.id = validator_id + self._kind = kind + self.state = _Attr() + self.conclusion = _Attr(ValidatorConclusion.UNKNOWN.value) + self.started_at = _Attr() + self.completed_at = _Attr() + self.save_count = 0 + + def get_kind(self) -> str: + return self._kind + + async def save(self) -> None: + self.save_count += 1 + + +async def _check(conclusion: ValidatorConclusion) -> ValidatorConclusion: + return conclusion + + +@dataclass +class CheckRunnerCase: + name: str + check_results: list[ValidatorConclusion] + expected_events: list[type] + expected_conclusion: str + + +CASES = [ + CheckRunnerCase( + name="no_checks_concludes_success", + check_results=[], + expected_events=[ValidatorPassedEvent], + expected_conclusion=ValidatorConclusion.SUCCESS.value, + ), + CheckRunnerCase( + name="all_checks_pass", + check_results=[ValidatorConclusion.SUCCESS, ValidatorConclusion.SUCCESS], + expected_events=[ValidatorPassedEvent], + expected_conclusion=ValidatorConclusion.SUCCESS.value, + ), + CheckRunnerCase( + name="a_failing_check_fails_the_validator", + check_results=[ValidatorConclusion.SUCCESS, ValidatorConclusion.FAILURE], + expected_events=[ValidatorFailedEvent], + expected_conclusion=ValidatorConclusion.FAILURE.value, + ), +] + + +def _make_context() -> InfrahubContext: + return InfrahubContext( + branch=BranchContext(name="main", id="00000000-0000-0000-0000-000000000000"), + account=AccountSession(account_id="00000000-0000-0000-0000-000000000001", auth_type=AuthType.API), + ) + + +@pytest.mark.parametrize("case", CASES, ids=lambda case: case.name) +async def test_run_checks_and_update_validator_emits_terminal_event_but_never_started( + case: CheckRunnerCase, +) -> None: + """The checks-runner emits a terminal (passed/failed) event but never a ``started`` event. + + ``started`` comes from a separate step, so a validator that concludes without going through + this function emits ``started`` but no terminal event — which is why a successful validator + can be counted in ``checks_started`` yet not ``checks_passed``. + """ + event_service = MemoryInfrahubEvent() + validator = FakeValidator(validator_id="18b00000-0000-0000-0000-0000000000aa", kind="CoreDataValidator") + checks = cast( + "list[Coroutine[Any, None, ValidatorConclusion]]", + [_check(result) for result in case.check_results], + ) + + await run_checks_and_update_validator( + checks=checks, + validator=cast("CoreValidator", validator), + context=_make_context(), + event_service=event_service, + proposed_change_id="18b00000-0000-0000-0000-0000000000bb", + ) + + emitted = [type(event) for event in event_service.events] + assert emitted == case.expected_events + # The crux: the checks-runner is the sole emitter of terminal events and never emits started. + assert ValidatorStartedEvent not in emitted + # The validator did complete — so a completed-successfully validator still emits no started. + assert validator.state.value == ValidatorState.COMPLETED.value + assert validator.conclusion.value == case.expected_conclusion diff --git a/dev/README.md b/dev/README.md index 811ed26aec2..13e4c907688 100644 --- a/dev/README.md +++ b/dev/README.md @@ -61,6 +61,7 @@ Backend architecture documentation in [knowledge/backend/](knowledge/backend/): - [events.md](knowledge/backend/events.md) - Events system - [async-tasks.md](knowledge/backend/async-tasks.md) - Asynchronous tasks (Prefect) - [message-bus.md](knowledge/backend/message-bus.md) - Message bus system +- [telemetry.md](knowledge/backend/telemetry.md) - Anonymous usage telemetry (categories, windowing, retention, degradation) ## Current ADRs diff --git a/dev/knowledge/backend/telemetry.md b/dev/knowledge/backend/telemetry.md new file mode 100644 index 00000000000..5dc51faf50f --- /dev/null +++ b/dev/knowledge/backend/telemetry.md @@ -0,0 +1,114 @@ +# Telemetry + +> Part of: `dev/knowledge/backend/` | Related: [Events System](events.md), [Asynchronous Tasks](async-tasks.md) + +Infrahub gathers an anonymous usage snapshot once a day. The snapshot is always stored locally +(so air-gapped and opted-out deployments still retain their own history) and, unless the +operator opts out, is also sent to the OpsMill telemetry endpoint. It exists to understand +adoption and scale, never to capture customer data. + +## Collection flow + +A daily Prefect flow (`anonymous_telemetry_send`, cron ~02:00 UTC with a per-deployment random +minute) gathers the payload, stores it as a `TelemetrySnapshot`, then conditionally sends it: + +```text +anonymous_telemetry_send (daily) + └─ gather_anonymous_telemetry_data() → TelemetryData + └─ TelemetrySnapshot.save() ← ALWAYS stored locally first + └─ opted out? → mark "skipped" + opted in? → POST to endpoint → mark "sent" / "failed" +``` + +The random cron minute spreads load across deployments; it is why the windowing below is +anchored to a calendar boundary rather than to the moment the flow happens to run. + +## What is collected — by category + +The payload groups metrics into categories. Fields are documented in the payload contract; the +distinction that matters operationally is each category's **temporal model** (below). + +| Category | What it covers | +|----------|----------------| +| Deployment | anonymous deployment id, Infrahub version/type, Python/platform | +| Workers | worker pool size and active count | +| Branches | total and open (non-system) branch counts | +| Accounts | active accounts, account groups | +| Schema | node/generic kind counts, last schema change | +| Features | how many objects of adoption-signalling kinds exist (artifacts, repos, generators, …) | +| Database | database type, node/relationship counts, server + host system info | +| Prefect | event tally, automation counts, work-pool state | +| Activity (24h) | logins, checks, artifacts, branch actions, webhook deliveries | + +## Temporal models (the important part) + +Not every number means the same thing over time. There are three kinds: + +1. **Point-in-time snapshot** — most metrics (node/relationship counts, accounts, branches, + schema, features, workers, database info) are the *current* value at gather time. Re-running + the flow reflects the graph as it is now. + +2. **Cumulative over Prefect retention (~7 days)** — the `prefect.events` tally is a raw count + of each event type that Prefect *still retains*. Prefect expires events after ~7 days, so + this is a rolling window bounded by retention — **not** a per-day figure and not comparable + day to day. This is the older, coarse signal. + +3. **Windowed — previous full UTC day** — every `activity_24h.*` metric counts only events (or + webhook flow-runs) that occurred within `[yesterday 00:00 UTC, today 00:00 UTC)`. This is the + precise daily signal that supersedes the coarse cumulative tally for activity. + +The contrast between (2) and (3) is deliberate: `prefect.events` answers "roughly how much of X +is Prefect holding right now", while `activity_24h` answers "exactly how much X happened +yesterday". + +## Windowing + +`get_activity_window()` returns the half-open interval `[start, end)` where `end` is midnight +UTC of the current day and `start` is 24h earlier — the previous full calendar day. Because it +is anchored to the midnight boundary (not to `now`), consecutive daily runs tile exactly with no +overlap or gap regardless of the jittered cron minute. The upper bound is exclusive; the event +counters pull their query's inclusive `until` back one microsecond so an event stamped exactly on +midnight lands in one window only, never two. + +### Retention interaction + +Prefect keeps events and flow-runs for ~7 days. The windowed metrics only ever look one day back, +so they are safe as long as the daily flow runs within retention (it runs every day, well inside +7 days). If the flow were down for several days, days beyond retention could not be recovered — +the metrics are a live daily sample, not a backfillable ledger. + +## Graceful degradation + +Every metric source is gathered through a single helper (`safe_metric`) that isolates failures: +if a source raises, that field is reported as `null` (and the failure is logged) while the rest +of the payload is still built, stored, and sent. A source that succeeds with nothing to count +reports `0`. So **`null` means "could not measure", `0` means "measured, nothing there"**. + +One caveat on the check metrics: `checks_started` counts every validator that starts, but +`checks_passed`/`checks_failed` are only emitted for validators that run through the checks +runner. A validator that concludes without executing checks (a trivial no-op) is counted in +`checks_started` only, so `started` can exceed `passed + failed` without any run being +incomplete. + +## Storage & access + +Snapshots are stored as `TelemetrySnapshot` nodes with a `remote_send_status` +(`pending`/`sent`/`skipped`/`failed`). They are readable regardless of opt-out via +`infrahubctl telemetry list` / `infrahubctl telemetry export` or `GET /api/telemetry/snapshots`, +both gated on the `READ_TELEMETRY` global permission. + +## Key Locations + +| Path | Purpose | +|------|---------| +| `backend/infrahub/telemetry/tasks.py` | Daily flow, payload assembly, remote send | +| `backend/infrahub/telemetry/window.py` | The 24h window function | +| `backend/infrahub/telemetry/task_manager.py` | Windowed event / webhook-run counters | +| `backend/infrahub/telemetry/database.py` | Database and node-count metrics | +| `backend/infrahub/telemetry/models.py` | Payload schema | +| `backend/infrahub/workflows/catalogue.py` | Registers the `anonymous_telemetry_send` deployment | + +## See Also + +- [Events System](events.md) — the Prefect events the activity metrics count +- [Asynchronous Tasks](async-tasks.md) — how the daily flow is scheduled and run diff --git a/dev/specs/telemetry-collection-infp-589/data-model.md b/dev/specs/telemetry-collection-infp-589/data-model.md index 738cd8c3e4d..03a41a6f20c 100644 --- a/dev/specs/telemetry-collection-infp-589/data-model.md +++ b/dev/specs/telemetry-collection-infp-589/data-model.md @@ -115,3 +115,10 @@ a whole-source failure surfaces as nulled fields, never a missing object (SC-001 ## Out of model (this phase) - No changes to `TelemetryPrefectData.events` (the existing unwindowed tally). +- `database.system_info.processor_configured` (configured DB core count, intended for future + license reporting) is **deferred**. It was prototyped reading Neo4j `SHOW SETTINGS` for + `server.threads.worker_count`, but that setting is REST-only — it does not govern the Bolt + path Infrahub uses — and defaults to the host core count, so today it would only duplicate + `processor_available`. Revisit once the correct "licensed cores" setting is confirmed: Neo4j + exposes no single canonical one (`server.cypher.parallel.worker_limit` is the other + candidate, or the true signal may be the JVM/container CPU allocation). diff --git a/dev/specs/telemetry-collection-infp-589/manual-test-runbook.md b/dev/specs/telemetry-collection-infp-589/manual-test-runbook.md new file mode 100644 index 00000000000..1a32a594a6e --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/manual-test-runbook.md @@ -0,0 +1,125 @@ +# Telemetry Phase 1 — Manual Test Plan + +Validates the daily telemetry payload end to end on a running Infrahub instance. It assumes you +can already spin up Infrahub (the standard dev stack via `uv run invoke dev.start`) and have an +admin API token. For the concepts behind these metrics — categories, windowing, retention, +degradation — see `dev/knowledge/backend/telemetry.md`. + +## What you are validating + +The Phase 1 payload additions: + +- **Point-in-time**: `accounts.active`, `accounts.groups`, `branches.active`, + `database.node_count.corenode`, `database.node_count.user` — checked against an independent + source (GraphQL) or an invariant. +- **Windowed**: the `activity_24h.*` block (logins, checks, artifacts, branch actions, webhook + deliveries) — checked with the bundled `window_probe.py`. + +## Prerequisites + +Container names below assume the default `infrahub` compose project; adjust if yours differ. + +```fish +set -x INFRAHUB_ADDRESS http://localhost:8000 +set -x INFRAHUB_API_TOKEN (docker exec infrahub-server-1 printenv INFRAHUB_INITIAL_ADMIN_TOKEN) +set P /source/dev/specs/telemetry-collection-infp-589/window_probe.py # probe path inside the worker +``` + +The repo is bind-mounted into the worker at `/source`, so the probe runs there without copying. + +## 1. Trigger a collection on demand + +The flow runs daily at ~02:00 UTC; trigger it now instead of waiting: + +```bash +docker exec infrahub-task-worker-1 prefect deployment run 'anonymous_telemetry_send/anonymous_telemetry_send' +``` + +Wait ~20s for a worker to pick it up. + +> With `telemetry_optout=false` (default) this also POSTs one anonymous payload to the real +> endpoint — the same thing the daily cron does. The snapshot is stored locally *before* the +> send regardless, so inspection never depends on it. To avoid the send, set +> `INFRAHUB_TELEMETRY_OPTOUT=true` on the worker and recreate it first. + +## 2. See the payload + +```fish +uv run infrahubctl telemetry list # newest row = the run you just triggered +uv run infrahubctl telemetry export --output /tmp/t.json +python3 -c " +import json +d = json.load(open('/tmp/t.json'))[0]['data'] +print('accounts :', d['accounts']) +print('branches :', d['branches']) +print('node_count :', {k: d['database']['node_count'][k] for k in ('total', 'corenode', 'user')}) +print('activity_24h:', json.dumps(d['activity_24h'], indent=2)) +" +``` + +## 3. Validate the point-in-time metrics + +These reflect current state, so trigger a fresh snapshot (step 1) before comparing. + +`accounts` — GraphQL counts must equal `accounts.active` / `accounts.groups`: + +```bash +curl -s -H "X-INFRAHUB-KEY: $INFRAHUB_API_TOKEN" -H "Content-Type: application/json" \ + -d '{"query":"query { active: CoreAccount(status__value:\"active\"){count} groups: CoreAccountGroup{count} }"}' \ + http://localhost:8000/graphql/main +``` + +`branches.active` — GraphQL branch list minus `main` must equal it (`branches.total` is 2 higher: +it also counts `main` and the internal `-global-` branch): + +```bash +curl -s -H "X-INFRAHUB-KEY: $INFRAHUB_API_TOKEN" -H "Content-Type: application/json" \ + -d '{"query":"query { Branch { name is_default } }"}' http://localhost:8000/graphql/main \ + | python3 -c "import sys,json;b=json.load(sys.stdin)['data']['Branch'];print('active =', len([x for x in b if not x['is_default']]))" +``` + +`node_count` — verify the invariant from step 2's output: `user ≤ corenode ≤ total`, and +`user < corenode` (the always-present `Core` management namespace lifts `corenode` above `user`). + +## 4. Validate the windowed metrics (`activity_24h`) + +`window_probe.py` counts one metric in three windows: **YESTERDAY** (what the snapshot reports), +**TODAY** (what tomorrow's snapshot will report), **LAST 3H** (fresh activity). + +```bash +docker exec infrahub-task-worker-1 python $P logins +``` + +Two checks: + +1. **Consistency** — the snapshot's `activity_24h.` equals the probe's **YESTERDAY** count. +2. **Windowing (live)** — do the action now (e.g. `uv run infrahubctl branch create test-$(date +%s)`, + or log in at the UI), re-run the probe: **TODAY** and **LAST 3H** rise while **YESTERDAY** stays + frozen. That is the guarantee — today's events never leak into yesterday's closed window. + +Metrics accepted: `logins`, `branches_created` / `_merged` / `_deleted`, `checks_started` / +`_passed` / `_failed`, `artifacts_created` / `_updated`, and `webhooks` (success/failure). + +## 5. Graceful degradation + +`null` means a source failed (and was logged); `0` means it was measured with nothing to count. A +healthy run has no `null`s. To see a source degrade in isolation without breaking the rest, point +one metric's source at a failing dependency — only that field goes `null`. + +## Caveats worth knowing + +- **`checks_passed` / `checks_failed`** are only emitted for validators that run the checks + runner. A trivial proposed change concludes its integrity validators without executing checks, + so it produces `checks_started` only — `started` can exceed `passed + failed` with nothing + actually incomplete. To move `passed`/`failed`, use a proposed change with real conflicts or a + connected repository with checks. +- **`webhooks_fired_*`** counts `webhook-process` flow-runs. In a bare dev stack the + event → automation delivery may not fire on its own; generate real webhook traffic (a webhook + subscribed to `all` events plus a triggering mutation) to exercise it. + +## The ad-hoc probe (`window_probe.py`) + +The script lives beside this file. It evaluates the production windowed counters against a chosen +reference time, so you can confirm just-now activity is captured without waiting for the calendar +day to roll. Run it inside a worker container (which has the Prefect client and `PREFECT_API_URL` +configured); pass an `activity_24h` field name as the argument (defaults to `logins`). diff --git a/dev/specs/telemetry-collection-infp-589/window_probe.py b/dev/specs/telemetry-collection-infp-589/window_probe.py new file mode 100644 index 00000000000..1e55864b254 --- /dev/null +++ b/dev/specs/telemetry-collection-infp-589/window_probe.py @@ -0,0 +1,83 @@ +# ruff: noqa: INP001 # standalone manual-testing script, not a package module +"""Ad-hoc telemetry window probe (manual testing aid; not part of the app or tests). + +Counts one activity_24h metric across three windows so a just-now action can be confirmed +without waiting for the calendar day to roll: + + YESTERDAY -> the window today's telemetry snapshot reports + TODAY -> the window tomorrow's snapshot will report (where an action done now lands) + LAST 3H -> a tight window around right now, to isolate the action you just took + +Run inside a worker container (has the Prefect client + PREFECT_API_URL): + + docker exec infrahub-task-worker-1 python /source/dev/specs/telemetry-collection-infp-589/window_probe.py [metric] + +`metric` defaults to `logins`. Event-based metrics: logins, branches_created/merged/deleted, +checks_started/passed/failed, artifacts_created/updated (or a raw Prefect event name). +The special metric `webhooks` counts webhook-process flow-runs and reports success/failure. +""" + +import asyncio +import sys +from datetime import UTC, datetime, timedelta + +from prefect.client.orchestration import get_client + +from infrahub.events.account_action import AccountLoggedInEvent +from infrahub.events.artifact_action import ArtifactCreatedEvent, ArtifactUpdatedEvent +from infrahub.events.branch_action import BranchCreatedEvent, BranchDeletedEvent, BranchMergedEvent +from infrahub.events.validator_action import ValidatorFailedEvent, ValidatorPassedEvent, ValidatorStartedEvent +from infrahub.telemetry.task_manager import count_webhook_runs, count_windowed_event, count_windowed_unique_resources +from infrahub.telemetry.window import get_activity_window + +EVENTS = { + "logins": AccountLoggedInEvent.event_name, + "branches_created": BranchCreatedEvent.event_name, + "branches_merged": BranchMergedEvent.event_name, + "branches_deleted": BranchDeletedEvent.event_name, + "checks_started": ValidatorStartedEvent.event_name, + "checks_passed": ValidatorPassedEvent.event_name, + "checks_failed": ValidatorFailedEvent.event_name, + "artifacts_created": ArtifactCreatedEvent.event_name, + "artifacts_updated": ArtifactUpdatedEvent.event_name, +} + +key = sys.argv[1] if len(sys.argv) > 1 else "logins" + + +def windows_for(now: datetime) -> dict[str, tuple[datetime, datetime]]: + return { + "YESTERDAY (today's snapshot)": get_activity_window(now), + "TODAY (tomorrow's snapshot) ": get_activity_window(now + timedelta(days=1)), + "LAST 3H (activity just now)": (now - timedelta(hours=3), now), + } + + +async def probe_webhooks(client: object, windows: dict[str, tuple[datetime, datetime]]) -> None: + for label, (start, end) in windows.items(): + success, failure = await count_webhook_runs.fn(client=client, window_start=start, window_end=end) + print(f"{label} success={success} failure={failure}") + + +async def probe_event(client: object, event: str, windows: dict[str, tuple[datetime, datetime]]) -> None: + for label, (start, end) in windows.items(): + total = await count_windowed_event.fn(client=client, event_name=event, window_start=start, window_end=end) + unique = await count_windowed_unique_resources.fn( + client=client, event_name=event, window_start=start, window_end=end + ) + print(f"{label} count={total} unique={unique}") + + +async def main() -> None: + now = datetime.now(tz=UTC) + windows = windows_for(now) + print(f"metric: {key}") + print(f"now : {now.isoformat()}\n") + async with get_client(sync_client=False) as client: + if key == "webhooks": + await probe_webhooks(client, windows) + else: + await probe_event(client, EVENTS.get(key, key), windows) + + +asyncio.run(main()) From 399351d36f7fdee2eb99ae94f66cc465eee37f9c Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Fri, 3 Jul 2026 16:38:35 +0200 Subject: [PATCH 24/75] fix(telemetry): cast test event timestamps to Prefect's DateTime Event.occurred is typed as Prefect's DateTime subclass, not stdlib datetime; mypy rejected the plain datetime built for window-boundary test fixtures. Co-Authored-By: Claude Opus 4.8 --- backend/tests/component/telemetry/test_task_manager.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/tests/component/telemetry/test_task_manager.py b/backend/tests/component/telemetry/test_task_manager.py index 8263d23981e..b5c50ff98d2 100644 --- a/backend/tests/component/telemetry/test_task_manager.py +++ b/backend/tests/component/telemetry/test_task_manager.py @@ -3,6 +3,7 @@ from collections.abc import AsyncGenerator, Generator from dataclasses import dataclass from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING, cast import pytest from prefect.client.orchestration import PrefectClient, get_client @@ -23,6 +24,9 @@ ) from infrahub.telemetry.window import floor_to_midnight_utc, get_activity_window +if TYPE_CHECKING: + from prefect.types import DateTime + LOGIN_EVENT_NAME = "infrahub.account.logged_in" WEBHOOK_FLOW_NAME = "webhook-process" @@ -50,7 +54,7 @@ def _login_event(account_id: str, occurred: datetime) -> Event: return Event( id=uuid.uuid4(), event=LOGIN_EVENT_NAME, - occurred=occurred, + occurred=cast("DateTime", occurred), resource=Resource({"prefect.resource.id": f"infrahub.account.{account_id}"}), ) @@ -64,7 +68,7 @@ def _named_event(event_name: str, occurred: datetime, resource_id: str) -> Event return Event( id=uuid.uuid4(), event=event_name, - occurred=occurred, + occurred=cast("DateTime", occurred), resource=Resource({"prefect.resource.id": resource_id}), ) From 3f255553803cd2cf467403b6ec49e8e943bd9bb2 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Fri, 3 Jul 2026 17:01:45 +0200 Subject: [PATCH 25/75] fix(telemetry): address review feedback on DI seams, docs, and CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the None-sentinel-plus-or-fallback pattern in gather_anonymous_telemetry_data with direct callable defaults — the production gatherers are already plain functions defined above their use site, so the optional-with-internal-default shape this project's DI guideline flags was unnecessary here. Drop the redundant __all__ re-export (safe_metric is already used directly in this module). Document activity_24h field semantics in the telemetry knowledge doc, clarifying that logins/unique_logins count interactive sign-ins (password, OIDC, OAuth2) only — per-request API-key/token auth never emits a login event. Revert the CLAUDE.md Spec Kit plan-pointer stamp: the same pattern was already reverted once on another feature branch (37d0417f6) since it hard-codes one feature's plan path into a file shared across all branches. Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/telemetry/tasks.py | 16 +++++----------- dev/knowledge/backend/telemetry.md | 10 ++++++++++ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/backend/infrahub/telemetry/tasks.py b/backend/infrahub/telemetry/tasks.py index 87bd64b6507..dab62d49ca4 100644 --- a/backend/infrahub/telemetry/tasks.py +++ b/backend/infrahub/telemetry/tasks.py @@ -40,8 +40,6 @@ log = logging.getLogger(__name__) -__all__ = ["safe_metric"] - @task(name="telemetry-schema-information", task_run_name="Gather Schema Information", cache_policy=NONE) async def gather_schema_information(branch: Branch) -> TelemetrySchemaData: @@ -143,23 +141,19 @@ async def _default_activity_24h_gatherer() -> TelemetryActivity24hData: @task(name="telemetry-gather-data", task_run_name="Gather Anonynous Data", cache_policy=NONE) async def gather_anonymous_telemetry_data( - account_gatherer: Callable[[InfrahubDatabase], Awaitable[TelemetryAccountData]] | None = None, - activity_gatherer: Callable[[], Awaitable[TelemetryActivity24hData]] | None = None, - active_branch_counter: Callable[[], Awaitable[int]] | None = None, + account_gatherer: Callable[[InfrahubDatabase], Awaitable[TelemetryAccountData]] = gather_account_information, + activity_gatherer: Callable[[], Awaitable[TelemetryActivity24hData]] = _default_activity_24h_gatherer, + active_branch_counter: Callable[[], Awaitable[int]] = count_active_branches, ) -> TelemetryData: """Assemble the full telemetry payload, isolating every metric source. Each source is gathered through the degradation helper, so one failing source nulls only its own field(s) while the rest is still built. The ``*_gatherer`` / ``*_counter`` params - are injection seams (production passes none) that let the per-source isolation be tested - with a real failing collaborator instead of patching. + are injection seams that let the per-source isolation be tested with a real failing + collaborator instead of patching. """ start_time = time.time() - account_gatherer = account_gatherer or gather_account_information - activity_gatherer = activity_gatherer or _default_activity_24h_gatherer - active_branch_counter = active_branch_counter or count_active_branches - default_branch = registry.get_branch_from_registry() database = await get_database() component = await get_component() diff --git a/dev/knowledge/backend/telemetry.md b/dev/knowledge/backend/telemetry.md index 5dc51faf50f..209f9c481a6 100644 --- a/dev/knowledge/backend/telemetry.md +++ b/dev/knowledge/backend/telemetry.md @@ -40,6 +40,16 @@ distinction that matters operationally is each category's **temporal model** (be | Prefect | event tally, automation counts, work-pool state | | Activity (24h) | logins, checks, artifacts, branch actions, webhook deliveries | +### Activity (24h) field semantics + +| Field | Counts | +|-------|--------| +| `logins` / `unique_logins` | Interactive sign-ins only — password, OIDC, OAuth2. Per-request API-key/token authentication is stateless and never emits a login event, so token-authenticated SDK/CI traffic is **not** included. | +| `checks_started` / `_passed` / `_failed` | Validator lifecycle events (see the checks caveat under Graceful degradation). | +| `artifacts_created` / `_updated` | Artifact lifecycle events. | +| `branches_created` / `_merged` / `_deleted` | Branch lifecycle events. | +| `webhooks_fired_success` / `_failure` | Terminal `webhook-process` flow-run states. | + ## Temporal models (the important part) Not every number means the same thing over time. There are three kinds: From 2006198f25a4c945f5d0696c4ebc3bc310aa778f Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Fri, 3 Jul 2026 17:19:42 +0200 Subject: [PATCH 26/75] fix(telemetry): fix ty CI failure and reconcile spec docs with implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit window_probe.py typed its Prefect client parameters as `object` instead of PrefectClient, which the CI ty-check gate rejects when passed to count_webhook_runs.fn/count_windowed_event.fn. Also reconcile spec docs raised in review: - spec.md: document that configured database-core reporting was pulled into Phase 1 per the Jira scope-change call, then deferred back out during implementation (the candidate setting is REST-only and would only duplicate processor_available) — the Out of Scope section previously gave no reason, reading as an oversight rather than a deliberate call. - tasks.md: check off T021b/T021c — node_count["user"] is implemented and tested, the checklist just wasn't updated. - data-model.md, contracts/telemetry-payload.md, research.md: drop TIMEDOUT from the webhook-failure states — it isn't a real Prefect StateType; the collector only counts FAILED/CRASHED. Already flagged as a deferred doc fix in opsmill-implement-report.md; finishing it now. Co-Authored-By: Claude Opus 4.8 --- .../contracts/telemetry-payload.md | 2 +- dev/specs/telemetry-collection-infp-589/data-model.md | 2 +- dev/specs/telemetry-collection-infp-589/research.md | 3 ++- dev/specs/telemetry-collection-infp-589/spec.md | 8 ++++++++ dev/specs/telemetry-collection-infp-589/tasks.md | 6 +++--- dev/specs/telemetry-collection-infp-589/window_probe.py | 6 +++--- 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md b/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md index 9e1ac3e8537..f608fe6d0b9 100644 --- a/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md +++ b/dev/specs/telemetry-collection-infp-589/contracts/telemetry-payload.md @@ -85,7 +85,7 @@ deterministic boundary (not gather-time `now`) so daily snapshots tile exactly. | `activity_24h.branches_merged` | Prefect `branch.merged` events, windowed | prev. UTC day | `0` | `null` | | `activity_24h.branches_deleted` | Prefect `branch.deleted` events, windowed | prev. UTC day | `0` | `null` | | `activity_24h.webhooks_fired_success` | Prefect `webhook-process` flow runs, `COMPLETED` | prev. UTC day | `0` | `null` | -| `activity_24h.webhooks_fired_failure` | Prefect `webhook-process` flow runs, `FAILED`/`CRASHED`/`TIMEDOUT` | prev. UTC day | `0` | `null` | +| `activity_24h.webhooks_fired_failure` | Prefect `webhook-process` flow runs, `FAILED`/`CRASHED` | prev. UTC day | `0` | `null` | **Interpretation notes (checks vs webhooks).** diff --git a/dev/specs/telemetry-collection-infp-589/data-model.md b/dev/specs/telemetry-collection-infp-589/data-model.md index 03a41a6f20c..cf3718841ae 100644 --- a/dev/specs/telemetry-collection-infp-589/data-model.md +++ b/dev/specs/telemetry-collection-infp-589/data-model.md @@ -35,7 +35,7 @@ exactly (no overlap, no gap) despite the jittered cron minute and execution drif | `branches_merged` | `int \| None` | `branch.merged` events in-window | `0` | `null` | | `branches_deleted` | `int \| None` | `branch.deleted` events in-window | `0` | `null` | | `webhooks_fired_success` | `int \| None` | `webhook-process` flow runs in-window ending `COMPLETED`| `0` | `null` | -| `webhooks_fired_failure` | `int \| None` | `webhook-process` flow runs in-window ending `FAILED`/`CRASHED`/`TIMEDOUT` | `0` | `null` | +| `webhooks_fired_failure` | `int \| None` | `webhook-process` flow runs in-window ending `FAILED`/`CRASHED` | `0` | `null` | The eight check/artifact/branch fields are derived from events that are **already emitted and counted today** (windowless) via `get_all_events()`; they reuse the windowed event-count path diff --git a/dev/specs/telemetry-collection-infp-589/research.md b/dev/specs/telemetry-collection-infp-589/research.md index 4dc23903cba..2cd272a948f 100644 --- a/dev/specs/telemetry-collection-infp-589/research.md +++ b/dev/specs/telemetry-collection-infp-589/research.md @@ -137,7 +137,8 @@ rejected: heavier, and Prefect's count-by primitives do it server-side (Constitu runs** of the `webhook-process` flow (grounded: `@flow(name="webhook-process")` in `webhook/tasks/process.py`) started within the **same `[window_start, window_end)` calendar-day window as the event metrics** (Decision 3 anchor — not `now`), split by terminal state: -`COMPLETED` ⇒ success; `FAILED` / `CRASHED` (and `TIMEDOUT`) ⇒ failure. Query via the Prefect +`COMPLETED` ⇒ success; `FAILED` / `CRASHED` ⇒ failure (`TIMEDOUT` is not a Prefect `StateType`). +Query via the Prefect client's flow-run read API filtered by flow name and `start_time` in `[window_start, window_end)`. **Rationale**: Webhook delivery is a flow run, not an InfrahubEvent, so flow-run state is the diff --git a/dev/specs/telemetry-collection-infp-589/spec.md b/dev/specs/telemetry-collection-infp-589/spec.md index 855745623f7..597b13defa7 100644 --- a/dev/specs/telemetry-collection-infp-589/spec.md +++ b/dev/specs/telemetry-collection-infp-589/spec.md @@ -374,6 +374,14 @@ in-window events. CLI/MCP/Sync adoption, and GraphQL/REST metrics. (Checks and artifacts, formerly considered Phase 2, are pulled into Phase 1 — see FR-012/FR-013 — because their events already flow and serve a stated Phase 1 goal.) +- **Configured database-core reporting** (`server.threads.worker_count` via + `SHOW SETTINGS`, alongside the existing physical `processor_available`) was + pulled into Phase 1 scope per the Jira card's 1 Jul scope-change note, then + deferred back out during implementation: the candidate setting is REST-only + (doesn't govern the Bolt path Infrahub uses) and defaults to the host core + count, so today it would only duplicate `processor_available`. Revisit once + the correct "licensed cores" setting is confirmed (open dependency on Fatih; + see `data-model.md` for the investigation detail). - **Held in Phase 2 even though their events already flow** (a bare count would be permanent contract surface — FR-011 — without clear standalone Phase 1 value): - **PR governance** — `proposed_change.*` events exist, but the useful metric diff --git a/dev/specs/telemetry-collection-infp-589/tasks.md b/dev/specs/telemetry-collection-infp-589/tasks.md index e8c0d8b3a18..6ca3ccaa2b6 100644 --- a/dev/specs/telemetry-collection-infp-589/tasks.md +++ b/dev/specs/telemetry-collection-infp-589/tasks.md @@ -85,7 +85,7 @@ repeat logins per account, and the existing `prefect.events.*` output is unchang - [X] T009b [US1] In `backend/infrahub/telemetry/task_manager.py` (or a small `telemetry/window.py` helper), add a deterministic window function returning `[window_start, window_end)` where `window_end = floor_to_midnight_utc(now)` and `window_start = window_end - 24h` (previous full UTC calendar day). All activity_24h queries use this — never raw `now`. - [X] T010 [US1] In `backend/infrahub/telemetry/task_manager.py`, add a NEW windowed event counter that posts to `/events/count-by/event` with an `occurred` window (`since = window_start`, `until = window_end` from T009b) plus the `event.name` filter — separate from `gather_prefect_events`, which stays untouched. - [X] T011 [US1] In `task_manager.py`, add a windowed unique-account counter posting to `/events/count-by/resource` over the same `account.logged_in` window; the number of resource buckets (keyed by `infrahub.account.{account_id}`) is `unique_logins`. -- [X] T012 [US1] In `task_manager.py`, add a `webhook-process` flow-run query over the same `[window_start, window_end)` window (T009b), splitting terminal states into success (`COMPLETED`) and failure (`FAILED`/`CRASHED`/`TIMEDOUT`); non-terminal runs counted in neither. +- [X] T012 [US1] In `task_manager.py`, add a `webhook-process` flow-run query over the same `[window_start, window_end)` window (T009b), splitting terminal states into success (`COMPLETED`) and failure (`FAILED`/`CRASHED`); non-terminal runs counted in neither. - [X] T013 [US1] In `task_manager.py`, add `gather_activity_24h(client) -> TelemetryActivity24hData` assembling the login + webhook counts (US1 fields), each obtained through the degradation helper so one failing source nulls only its own field. (US5 extends this same function with the check/artifact counts.) - [X] T014 [US1] In `backend/infrahub/telemetry/tasks.py`, wire `activity_24h` into `gather_anonymous_telemetry_data` (gather via the Prefect client path; the object is always present). @@ -132,8 +132,8 @@ and assert `node_count["user"]` counts only the user-defined ones with `user ⊆ - [x] T020 [US3] In `backend/infrahub/telemetry/database.py`, set `node_count["corenode"]` via `NodeManager.count(db, schema=InfrahubKind.NODE, branch=)`, wrapped so a failure sets `corenode=None` without affecting `node_count["total"]` or the existing graph-label keys (do NOT use raw `count_nodes(label=...)`). - [x] T021 [US3] Add/extend a docstring or module note distinguishing the three node metrics at the namespace level: `total` (raw vertices), `corenode` (all managed nodes — `Core` + `Builtin` + user-defined namespaces), and `user` (customer-facing subset excluding the `Core` management namespace), noting they nest `user ⊆ corenode ⊆ total`. No tickets/IDs in source. -- [ ] T021b [US3] Component test in `backend/tests/component/telemetry/test_datatabase.py`: seed user-defined nodes (`Test` namespace via `car_person_schema`) + at least one `Core` node (a `CoreAccount`); assert `node_count["user"]` equals the user-defined count exactly (Core node excluded) and `user <= corenode <= total`, with `user < corenode` when a Core node exists. -- [ ] T021c [US3] In `backend/infrahub/telemetry/database.py`, set `node_count["user"]` = sum of `NodeManager.count` over concrete node kinds in user-editable namespaces (`SchemaNamespace.user_editable`, i.e. `namespace not in RESTRICTED_NAMESPACES`), excluding group-generic kinds; wrapped so a failure sets only `user=None`. Update `test_tasks.py` full-payload presence test to assert `node_count["user"]` is present. +- [x] T021b [US3] Component test in `backend/tests/component/telemetry/test_datatabase.py`: seed user-defined nodes (`Test` namespace via `car_person_schema`) + at least one `Core` node (a `CoreAccount`); assert `node_count["user"]` equals the user-defined count exactly (Core node excluded) and `user <= corenode <= total`, with `user < corenode` when a Core node exists. +- [x] T021c [US3] In `backend/infrahub/telemetry/database.py`, set `node_count["user"]` = sum of `NodeManager.count` over concrete node kinds in user-editable namespaces (`SchemaNamespace.user_editable`, i.e. `namespace not in RESTRICTED_NAMESPACES`), excluding group-generic kinds; wrapped so a failure sets only `user=None`. Update `test_tasks.py` full-payload presence test to assert `node_count["user"]` is present. **Checkpoint**: `corenode` + `user` exact and branch-correct; `user` excludes `Core`/`Builtin`; raw `total` preserved. diff --git a/dev/specs/telemetry-collection-infp-589/window_probe.py b/dev/specs/telemetry-collection-infp-589/window_probe.py index 1e55864b254..69a586e4853 100644 --- a/dev/specs/telemetry-collection-infp-589/window_probe.py +++ b/dev/specs/telemetry-collection-infp-589/window_probe.py @@ -21,7 +21,7 @@ import sys from datetime import UTC, datetime, timedelta -from prefect.client.orchestration import get_client +from prefect.client.orchestration import PrefectClient, get_client from infrahub.events.account_action import AccountLoggedInEvent from infrahub.events.artifact_action import ArtifactCreatedEvent, ArtifactUpdatedEvent @@ -53,13 +53,13 @@ def windows_for(now: datetime) -> dict[str, tuple[datetime, datetime]]: } -async def probe_webhooks(client: object, windows: dict[str, tuple[datetime, datetime]]) -> None: +async def probe_webhooks(client: PrefectClient, windows: dict[str, tuple[datetime, datetime]]) -> None: for label, (start, end) in windows.items(): success, failure = await count_webhook_runs.fn(client=client, window_start=start, window_end=end) print(f"{label} success={success} failure={failure}") -async def probe_event(client: object, event: str, windows: dict[str, tuple[datetime, datetime]]) -> None: +async def probe_event(client: PrefectClient, event: str, windows: dict[str, tuple[datetime, datetime]]) -> None: for label, (start, end) in windows.items(): total = await count_windowed_event.fn(client=client, event_name=event, window_start=start, window_end=end) unique = await count_windowed_unique_resources.fn( From a0c31aa16a4eacac9c8d30a55ca97c8c7e282c82 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Mon, 6 Jul 2026 13:03:59 +0200 Subject: [PATCH 27/75] refactor(telemetry): dedup count-by/window helpers, address review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the half-open→inclusive boundary conversion (window_end minus one microsecond) into window.inclusive_end; the windowed event filter and the webhook flow-run filter now share it, with the rationale documented once. Extract the count-by POST boilerplate (post, raise, parse, list-guard) into a single _post_count_by helper reused by the three count-by callers. Review feedback: - Derive the webhook flow name from the workflow catalogue (WEBHOOK_PROCESS.name) instead of hardcoding the string. - Route safe_metric's degradation warning through the infrahub.tasks logger so it surfaces in Prefect task logs (a bare module logger does not). - Remove .specify/feature.json (speckit artifact, being gitignored upstream). Co-Authored-By: Claude Opus 4.8 --- .specify/feature.json | 3 -- backend/infrahub/telemetry/task_manager.py | 57 +++++++++------------- backend/infrahub/telemetry/utils.py | 5 +- backend/infrahub/telemetry/window.py | 10 ++++ 4 files changed, 35 insertions(+), 40 deletions(-) delete mode 100644 .specify/feature.json diff --git a/.specify/feature.json b/.specify/feature.json deleted file mode 100644 index 5f239a2420e..00000000000 --- a/.specify/feature.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "feature_directory": "specs/telemetry-collection-infp-589" -} diff --git a/backend/infrahub/telemetry/task_manager.py b/backend/infrahub/telemetry/task_manager.py index 78c1e9f4fe2..3b4a46418e8 100644 --- a/backend/infrahub/telemetry/task_manager.py +++ b/backend/infrahub/telemetry/task_manager.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime from typing import Any from prefect import task @@ -23,15 +23,24 @@ from infrahub.trigger.constants import NAME_SEPARATOR from infrahub.trigger.models import TriggerType from infrahub.trigger.setup import gather_all_automations +from infrahub.workflows.catalogue import WEBHOOK_PROCESS from .models import TelemetryActivity24hData, TelemetryPrefectData, TelemetryWorkPoolData from .utils import safe_metric -from .window import get_activity_window +from .window import get_activity_window, inclusive_end -WEBHOOK_FLOW_NAME = "webhook-process" +WEBHOOK_FLOW_NAME = WEBHOOK_PROCESS.name WEBHOOK_FAILURE_STATES = [StateType.FAILED, StateType.CRASHED] +async def _post_count_by(client: PrefectClient, path: str, payload: dict[str, Any]) -> list[Any]: + """POST a Prefect count-by query and return its buckets (empty when the response has none).""" + response = await client._client.post(path, json=payload) + response.raise_for_status() + data = response.json() + return data if isinstance(data, list) else [] + + @task(name="telemetry-gather-work-pools", task_run_name="Gather Work Pools", cache_policy=NONE) async def gather_prefect_work_pools(client: PrefectClient) -> list[TelemetryWorkPoolData]: work_pools = await client.read_work_pools() @@ -56,32 +65,20 @@ async def gather_prefect_events(client: PrefectClient) -> dict[str, Any]: infrahub_events = get_all_events() events: dict[str, int] = {} - async def count_events(event_name: str) -> int: - payload = {"filter": {"event": {"name": [event_name]}}} - response = await client._client.post("/events/count-by/event", json=payload) - response.raise_for_status() - data = response.json() - if not isinstance(data, list) or len(data) == 0: - return 0 - return data[0]["count"] - for event in infrahub_events: - events[event.event_name] = await count_events(event_name=event.event_name) + payload = {"filter": {"event": {"name": [event.event_name]}}} + buckets = await _post_count_by(client=client, path="/events/count-by/event", payload=payload) + events[event.event_name] = buckets[0]["count"] if buckets else 0 return events def _windowed_event_filter(event_name: str, window_start: datetime, window_end: datetime) -> dict[str, Any]: - """Build the count-by filter for ``[start, end)``. - - Prefect's ``EventOccurredFilter`` is inclusive on both ends, so ``until`` is pulled back a - microsecond to keep the window half-open — else a boundary event counts in two windows. - """ - until = window_end - timedelta(microseconds=1) + """Build the count-by filter for the half-open window ``[window_start, window_end)``.""" return { "filter": { "event": {"name": [event_name]}, - "occurred": {"since": window_start.isoformat(), "until": until.isoformat()}, + "occurred": {"since": window_start.isoformat(), "until": inclusive_end(window_end).isoformat()}, } } @@ -92,12 +89,8 @@ async def count_windowed_event( ) -> int: """Count events of one name that occurred within ``[window_start, window_end)``.""" payload = _windowed_event_filter(event_name=event_name, window_start=window_start, window_end=window_end) - response = await client._client.post("/events/count-by/event", json=payload) - response.raise_for_status() - data = response.json() - if not isinstance(data, list): - return 0 - return sum(bucket["count"] for bucket in data) + buckets = await _post_count_by(client=client, path="/events/count-by/event", payload=payload) + return sum(bucket["count"] for bucket in buckets) @task(name="telemetry-gather-windowed-unique", task_run_name="Gather Windowed Unique Count", cache_policy=NONE) @@ -110,12 +103,8 @@ async def count_windowed_unique_resources( distinct total. """ payload = _windowed_event_filter(event_name=event_name, window_start=window_start, window_end=window_end) - response = await client._client.post("/events/count-by/resource", json=payload) - response.raise_for_status() - data = response.json() - if not isinstance(data, list): - return 0 - return len(data) + buckets = await _post_count_by(client=client, path="/events/count-by/resource", payload=payload) + return len(buckets) @task(name="telemetry-gather-webhook-runs", task_run_name="Gather Webhook Runs", cache_policy=NONE) @@ -126,10 +115,8 @@ async def count_webhook_runs(client: PrefectClient, window_start: datetime, wind runs count in neither. """ flow_filter = FlowFilter(name=FlowFilterName(any_=[WEBHOOK_FLOW_NAME])) - # ``before_`` is inclusive, so pull the upper bound back a microsecond — a run on the - # boundary must land in exactly one window. after = DateTime.fromisoformat(window_start.isoformat()) - before = DateTime.fromisoformat((window_end - timedelta(microseconds=1)).isoformat()) + before = DateTime.fromisoformat(inclusive_end(window_end).isoformat()) def runs_in_states(states: list[StateType]) -> FlowRunFilter: return FlowRunFilter( diff --git a/backend/infrahub/telemetry/utils.py b/backend/infrahub/telemetry/utils.py index 533f0e1390f..fe988cc50e4 100644 --- a/backend/infrahub/telemetry/utils.py +++ b/backend/infrahub/telemetry/utils.py @@ -1,10 +1,11 @@ import importlib.metadata -import logging from collections.abc import Awaitable +from infrahub.log import get_run_logger + from .constants import InfrahubType -log = logging.getLogger(__name__) +log = get_run_logger() def determine_infrahub_type() -> InfrahubType: diff --git a/backend/infrahub/telemetry/window.py b/backend/infrahub/telemetry/window.py index 8547992d103..8102caf114d 100644 --- a/backend/infrahub/telemetry/window.py +++ b/backend/infrahub/telemetry/window.py @@ -19,3 +19,13 @@ def get_activity_window(now: datetime | None = None) -> tuple[datetime, datetime window_end = floor_to_midnight_utc(reference) window_start = window_end - WINDOW_LENGTH return window_start, window_end + + +def inclusive_end(window_end: datetime) -> datetime: + """The last instant inside the half-open ``[window_start, window_end)`` window. + + ``window_end`` itself is excluded; at microsecond resolution the last included instant is one + microsecond earlier. Query APIs whose upper bound is inclusive ("at or before") need this so + a record on the boundary lands in exactly one day's window, never two. + """ + return window_end - timedelta(microseconds=1) From 68e4c94ea0994584bf9d8ca848133a385aefb3ba Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Wed, 8 Jul 2026 12:51:01 +0200 Subject: [PATCH 28/75] docs(backend): document Prefect logging + workflow-name conventions Two review findings on the telemetry PR were avoidable mistakes worth writing down so they don't recur: a workflow name hardcoded instead of read from the catalogue, and a helper logging through a module logger Prefect never surfaces. Document both in the async-tasks knowledge doc (mechanism + why, naming the code), with brief authoring pointers in the creating-async-tasks guide, and nudge the backend router to load the knowledge base for relevant tasks. Align the telemetry test's webhook flow name to WEBHOOK_PROCESS.name to match production. Co-Authored-By: Claude Opus 4.8 --- backend/AGENTS.md | 2 +- .../tests/component/telemetry/test_task_manager.py | 3 ++- dev/guides/backend/creating-async-tasks.md | 12 +++++++++++- dev/knowledge/backend/async-tasks.md | 7 +++++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index a8a8bb4e5ce..68efef569fb 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -84,7 +84,7 @@ See `dev/knowledge/backend/testing.md` for detailed testing infrastructure docum - `dev/guidelines/backend/python.md` - Python coding standards - `dev/guidelines/changelog.md` - Changelog fragment creation -### Knowledge (How the system works) +### Knowledge (How the system works, load the knowledge base when working on relevant tasks) - `dev/knowledge/backend/architecture.md` - Backend architecture overview - `dev/knowledge/backend/testing.md` - Testing infrastructure and patterns diff --git a/backend/tests/component/telemetry/test_task_manager.py b/backend/tests/component/telemetry/test_task_manager.py index b5c50ff98d2..62d20ba1013 100644 --- a/backend/tests/component/telemetry/test_task_manager.py +++ b/backend/tests/component/telemetry/test_task_manager.py @@ -23,12 +23,13 @@ gather_prefect_information, ) from infrahub.telemetry.window import floor_to_midnight_utc, get_activity_window +from infrahub.workflows.catalogue import WEBHOOK_PROCESS if TYPE_CHECKING: from prefect.types import DateTime LOGIN_EVENT_NAME = "infrahub.account.logged_in" -WEBHOOK_FLOW_NAME = "webhook-process" +WEBHOOK_FLOW_NAME = WEBHOOK_PROCESS.name async def _post_events(client: PrefectClient, events: list[Event]) -> None: diff --git a/dev/guides/backend/creating-async-tasks.md b/dev/guides/backend/creating-async-tasks.md index e236de8feb2..03c63e51c00 100644 --- a/dev/guides/backend/creating-async-tasks.md +++ b/dev/guides/backend/creating-async-tasks.md @@ -88,6 +88,15 @@ The `flow_run_name` is visible to users in the Infrahub UI. Keep it clear and sh @flow(name="branch-merge", flow_run_name="Merge branch {branch} on {branch_id}") ``` +### Logging inside flows, tasks, and their helpers + +- Inside a `@flow` or `@task` body, use Prefect's `get_run_logger()`. +- In a plain helper called from within a flow (no run context, also called from tests), use + `infrahub.log.get_run_logger()` — the `infrahub.tasks` logger. + +A bare `logging.getLogger(__name__)` will not surface in Prefect. See the Logging section of +`dev/knowledge/backend/async-tasks.md` for why. + ### Step 3: Register the WorkflowDefinition Add your workflow to `backend/infrahub/workflows/catalogue.py`: @@ -264,7 +273,8 @@ Before submitting your workflow: - [ ] Workflow added to `WORKFLOWS` list - [ ] Correct `WorkflowType` selected (CORE/USER/INTERNAL) - [ ] `DATABASE_CHANGE` tag added if workflow modifies database -- [ ] Uses `get_run_logger()` for logging +- [ ] Logs use a Prefect-visible logger (`get_run_logger()` in flows/tasks; `infrahub.log.get_run_logger()` in helpers), never a bare module logger +- [ ] Workflow names referenced from other code come from `WorkflowDefinition.name`, not string literals - [ ] Uses dependency injection for services - [ ] Tests cover workflow execution (using local execution mode) - [ ] Code passes `uv run invoke lint` diff --git a/dev/knowledge/backend/async-tasks.md b/dev/knowledge/backend/async-tasks.md index c1576b6d2dc..cea05057ebe 100644 --- a/dev/knowledge/backend/async-tasks.md +++ b/dev/knowledge/backend/async-tasks.md @@ -220,6 +220,13 @@ Available dependencies: - `get_event_service()`: Event emission service - `get_component()`: Component registry access +## Logging + +Prefect only surfaces logs from its own run logger plus the loggers named in the worker's task-logger set — `DEFAULT_TASK_LOGGERS = ["infrahub.tasks"]` in `backend/infrahub/workers/infrahub_async.py`, extended by `config.SETTINGS.workflow.extra_loggers`. A bare `logging.getLogger(__name__)` sits outside that set, so its records never reach the task manager. + +- **Inside a `@flow` or `@task` body**, use Prefect's `get_run_logger()`. +- **In a plain helper** that runs inside a flow but is not itself decorated — so it has no Prefect run context, and is typically also called directly from tests — use `infrahub.log.get_run_logger()`. It returns the `infrahub.tasks` stdlib logger, which the worker registers with Prefect and which is safe to call with no run context (Prefect's own `get_run_logger()` raises outside a run). + ## Read Query Optimization in Prefect Tasks When a flow only needs a few fields from a node (e.g. `id`, `name`, `status`), avoid `client.all()`, `client.filters()`, or `client.get(prefetch_relationships=True)` — they fetch the full object graph. Use a targeted `execute_graphql()` call instead. From 2b949da363b0632048b350b06d9f2e60b12b6192 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Wed, 8 Jul 2026 13:19:13 +0200 Subject: [PATCH 29/75] refactor(telemetry): drop dead logger, fold window into utils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the unused module-level logger and its import in tasks.py — every log call lives inside the flow, which uses Prefect's run logger, so the module logger was dead and would not surface in Prefect regardless. Merge the small window module (window functions + WINDOW_LENGTH) into utils, delete window.py, and update the importers and the knowledge-doc key-locations table. Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/telemetry/task_manager.py | 3 +- backend/infrahub/telemetry/tasks.py | 3 -- backend/infrahub/telemetry/utils.py | 31 +++++++++++++++++++ backend/infrahub/telemetry/window.py | 31 ------------------- .../component/telemetry/test_task_manager.py | 2 +- dev/knowledge/backend/telemetry.md | 2 +- .../window_probe.py | 2 +- 7 files changed, 35 insertions(+), 39 deletions(-) delete mode 100644 backend/infrahub/telemetry/window.py diff --git a/backend/infrahub/telemetry/task_manager.py b/backend/infrahub/telemetry/task_manager.py index 3b4a46418e8..38204be31d7 100644 --- a/backend/infrahub/telemetry/task_manager.py +++ b/backend/infrahub/telemetry/task_manager.py @@ -26,8 +26,7 @@ from infrahub.workflows.catalogue import WEBHOOK_PROCESS from .models import TelemetryActivity24hData, TelemetryPrefectData, TelemetryWorkPoolData -from .utils import safe_metric -from .window import get_activity_window, inclusive_end +from .utils import get_activity_window, inclusive_end, safe_metric WEBHOOK_FLOW_NAME = WEBHOOK_PROCESS.name WEBHOOK_FAILURE_STATES = [StateType.FAILED, StateType.CRASHED] diff --git a/backend/infrahub/telemetry/tasks.py b/backend/infrahub/telemetry/tasks.py index dab62d49ca4..7e98d614151 100644 --- a/backend/infrahub/telemetry/tasks.py +++ b/backend/infrahub/telemetry/tasks.py @@ -1,6 +1,5 @@ import hashlib import json -import logging import platform import time from collections.abc import Awaitable, Callable @@ -38,8 +37,6 @@ from .task_manager import gather_activity_24h, gather_prefect_information from .utils import determine_infrahub_type, safe_metric -log = logging.getLogger(__name__) - @task(name="telemetry-schema-information", task_run_name="Gather Schema Information", cache_policy=NONE) async def gather_schema_information(branch: Branch) -> TelemetrySchemaData: diff --git a/backend/infrahub/telemetry/utils.py b/backend/infrahub/telemetry/utils.py index fe988cc50e4..209726170ae 100644 --- a/backend/infrahub/telemetry/utils.py +++ b/backend/infrahub/telemetry/utils.py @@ -1,5 +1,6 @@ import importlib.metadata from collections.abc import Awaitable +from datetime import UTC, datetime, timedelta from infrahub.log import get_run_logger @@ -7,6 +8,8 @@ log = get_run_logger() +WINDOW_LENGTH = timedelta(hours=24) + def determine_infrahub_type() -> InfrahubType: try: @@ -27,3 +30,31 @@ async def safe_metric[T](coro: Awaitable[T]) -> T | None: except Exception as exc: log.warning("Telemetry metric collection failed; reporting null for this field: %s", exc) return None + + +def floor_to_midnight_utc(moment: datetime) -> datetime: + """Return 00:00:00 UTC of the calendar day containing ``moment``.""" + in_utc = moment.astimezone(UTC) + return in_utc.replace(hour=0, minute=0, second=0, microsecond=0) + + +def get_activity_window(now: datetime | None = None) -> tuple[datetime, datetime]: + """Return the half-open ``[window_start, window_end)`` for the previous full UTC day. + + Anchored to the midnight-UTC boundary, not the raw ``now`` instant, so consecutive daily + runs tile exactly — no overlaps or gaps — whatever time the (jittered) cron fires. + """ + reference = now if now is not None else datetime.now(tz=UTC) + window_end = floor_to_midnight_utc(reference) + window_start = window_end - WINDOW_LENGTH + return window_start, window_end + + +def inclusive_end(window_end: datetime) -> datetime: + """The last instant inside the half-open ``[window_start, window_end)`` window. + + ``window_end`` itself is excluded; at microsecond resolution the last included instant is one + microsecond earlier. Query APIs whose upper bound is inclusive ("at or before") need this so + a record on the boundary lands in exactly one day's window, never two. + """ + return window_end - timedelta(microseconds=1) diff --git a/backend/infrahub/telemetry/window.py b/backend/infrahub/telemetry/window.py deleted file mode 100644 index 8102caf114d..00000000000 --- a/backend/infrahub/telemetry/window.py +++ /dev/null @@ -1,31 +0,0 @@ -from datetime import UTC, datetime, timedelta - -WINDOW_LENGTH = timedelta(hours=24) - - -def floor_to_midnight_utc(moment: datetime) -> datetime: - """Return 00:00:00 UTC of the calendar day containing ``moment``.""" - in_utc = moment.astimezone(UTC) - return in_utc.replace(hour=0, minute=0, second=0, microsecond=0) - - -def get_activity_window(now: datetime | None = None) -> tuple[datetime, datetime]: - """Return the half-open ``[window_start, window_end)`` for the previous full UTC day. - - Anchored to the midnight-UTC boundary, not the raw ``now`` instant, so consecutive daily - runs tile exactly — no overlaps or gaps — whatever time the (jittered) cron fires. - """ - reference = now if now is not None else datetime.now(tz=UTC) - window_end = floor_to_midnight_utc(reference) - window_start = window_end - WINDOW_LENGTH - return window_start, window_end - - -def inclusive_end(window_end: datetime) -> datetime: - """The last instant inside the half-open ``[window_start, window_end)`` window. - - ``window_end`` itself is excluded; at microsecond resolution the last included instant is one - microsecond earlier. Query APIs whose upper bound is inclusive ("at or before") need this so - a record on the boundary lands in exactly one day's window, never two. - """ - return window_end - timedelta(microseconds=1) diff --git a/backend/tests/component/telemetry/test_task_manager.py b/backend/tests/component/telemetry/test_task_manager.py index 62d20ba1013..b21db423308 100644 --- a/backend/tests/component/telemetry/test_task_manager.py +++ b/backend/tests/component/telemetry/test_task_manager.py @@ -22,7 +22,7 @@ gather_prefect_events, gather_prefect_information, ) -from infrahub.telemetry.window import floor_to_midnight_utc, get_activity_window +from infrahub.telemetry.utils import floor_to_midnight_utc, get_activity_window from infrahub.workflows.catalogue import WEBHOOK_PROCESS if TYPE_CHECKING: diff --git a/dev/knowledge/backend/telemetry.md b/dev/knowledge/backend/telemetry.md index 209f9c481a6..cda72ffa050 100644 --- a/dev/knowledge/backend/telemetry.md +++ b/dev/knowledge/backend/telemetry.md @@ -112,8 +112,8 @@ both gated on the `READ_TELEMETRY` global permission. | Path | Purpose | |------|---------| | `backend/infrahub/telemetry/tasks.py` | Daily flow, payload assembly, remote send | -| `backend/infrahub/telemetry/window.py` | The 24h window function | | `backend/infrahub/telemetry/task_manager.py` | Windowed event / webhook-run counters | +| `backend/infrahub/telemetry/utils.py` | Degradation helper, 24h window functions, infrahub-type detection | | `backend/infrahub/telemetry/database.py` | Database and node-count metrics | | `backend/infrahub/telemetry/models.py` | Payload schema | | `backend/infrahub/workflows/catalogue.py` | Registers the `anonymous_telemetry_send` deployment | diff --git a/dev/specs/telemetry-collection-infp-589/window_probe.py b/dev/specs/telemetry-collection-infp-589/window_probe.py index 69a586e4853..0b8d683d3cf 100644 --- a/dev/specs/telemetry-collection-infp-589/window_probe.py +++ b/dev/specs/telemetry-collection-infp-589/window_probe.py @@ -28,7 +28,7 @@ from infrahub.events.branch_action import BranchCreatedEvent, BranchDeletedEvent, BranchMergedEvent from infrahub.events.validator_action import ValidatorFailedEvent, ValidatorPassedEvent, ValidatorStartedEvent from infrahub.telemetry.task_manager import count_webhook_runs, count_windowed_event, count_windowed_unique_resources -from infrahub.telemetry.window import get_activity_window +from infrahub.telemetry.utils import get_activity_window EVENTS = { "logins": AccountLoggedInEvent.event_name, From 1d56930c166f93754ddb770e0a07f5c5fa801f2d Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Wed, 8 Jul 2026 13:22:12 +0200 Subject: [PATCH 30/75] chore: leave .specify/feature.json untouched in this PR The branch had removed this speckit runtime pointer; restore develop's version so this PR carries no diff for it. Its removal/gitignore is handled separately. Co-Authored-By: Claude Opus 4.8 --- .specify/feature.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .specify/feature.json diff --git a/.specify/feature.json b/.specify/feature.json new file mode 100644 index 00000000000..1be33c1f1c0 --- /dev/null +++ b/.specify/feature.json @@ -0,0 +1,3 @@ +{ + "feature_directory": "specs/001-entities-arch-migration" +} From 0b90d0970de88da38b2952b3cd0079843062c8df Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Wed, 8 Jul 2026 13:44:46 +0200 Subject: [PATCH 31/75] refactor(telemetry): default degradable fields to None, drop empty-object helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The null-filled fallback objects re-listed every field of TelemetryAccountData and TelemetryActivity24hData as None — a second source of truth for the field set. Default those fields to None on the models (matching TelemetryBranchData), so the empty case is just Model() and the field list lives only on the model. Delete the two _empty_* helpers and inline the fallback at the call sites. Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/telemetry/models.py | 28 ++++++++++++++-------------- backend/infrahub/telemetry/tasks.py | 27 ++------------------------- 2 files changed, 16 insertions(+), 39 deletions(-) diff --git a/backend/infrahub/telemetry/models.py b/backend/infrahub/telemetry/models.py index 0f47151b3ff..6e9deafe340 100644 --- a/backend/infrahub/telemetry/models.py +++ b/backend/infrahub/telemetry/models.py @@ -14,23 +14,23 @@ class TelemetryBranchData(BaseModel): class TelemetryAccountData(BaseModel): - active: int | None - groups: int | None + active: int | None = None + groups: int | None = None class TelemetryActivity24hData(BaseModel): - logins: int | None - unique_logins: int | None - checks_started: int | None - checks_passed: int | None - checks_failed: int | None - artifacts_created: int | None - artifacts_updated: int | None - branches_created: int | None - branches_merged: int | None - branches_deleted: int | None - webhooks_fired_success: int | None - webhooks_fired_failure: int | None + logins: int | None = None + unique_logins: int | None = None + checks_started: int | None = None + checks_passed: int | None = None + checks_failed: int | None = None + artifacts_created: int | None = None + artifacts_updated: int | None = None + branches_created: int | None = None + branches_merged: int | None = None + branches_deleted: int | None = None + webhooks_fired_success: int | None = None + webhooks_fired_failure: int | None = None class TelemetrySchemaData(BaseModel): diff --git a/backend/infrahub/telemetry/tasks.py b/backend/infrahub/telemetry/tasks.py index 7e98d614151..c7d5b41f5b3 100644 --- a/backend/infrahub/telemetry/tasks.py +++ b/backend/infrahub/telemetry/tasks.py @@ -107,29 +107,6 @@ async def count_active_branches() -> int: return len([branch for branch in registry.branch.values() if not branch.is_default and not branch.is_global]) -def _empty_account_data() -> TelemetryAccountData: - """Null-filled accounts object used when the whole accounts source fails.""" - return TelemetryAccountData(active=None, groups=None) - - -def _empty_activity_24h_data() -> TelemetryActivity24hData: - """Null-filled activity_24h object used when the whole activity source fails.""" - return TelemetryActivity24hData( - logins=None, - unique_logins=None, - checks_started=None, - checks_passed=None, - checks_failed=None, - artifacts_created=None, - artifacts_updated=None, - branches_created=None, - branches_merged=None, - branches_deleted=None, - webhooks_fired_success=None, - webhooks_fired_failure=None, - ) - - async def _default_activity_24h_gatherer() -> TelemetryActivity24hData: """Open a Prefect client and assemble the windowed 24h activity metrics.""" async with get_prefect_client(sync_client=False) as prefect_client: @@ -174,8 +151,8 @@ async def gather_anonymous_telemetry_data( total=len(registry.branch), active=await safe_metric(active_branch_counter()), ), - accounts=accounts if accounts is not None else _empty_account_data(), - activity_24h=activity_24h if activity_24h is not None else _empty_activity_24h_data(), + accounts=accounts if accounts is not None else TelemetryAccountData(), + activity_24h=activity_24h if activity_24h is not None else TelemetryActivity24hData(), features=await gather_feature_information(), schema_info=await gather_schema_information(branch=default_branch), database=await gather_database_information(db=database), From 8f33f51c915385ce3304abe7e3018dfb20cd0e2e Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Wed, 8 Jul 2026 13:53:58 +0200 Subject: [PATCH 32/75] chore: remove .specify/feature.json Drop the speckit runtime pointer; it is gitignored going forward (#9806). Co-Authored-By: Claude Opus 4.8 --- .specify/feature.json | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .specify/feature.json diff --git a/.specify/feature.json b/.specify/feature.json deleted file mode 100644 index 1be33c1f1c0..00000000000 --- a/.specify/feature.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "feature_directory": "specs/001-entities-arch-migration" -} From 47c863fe9a72f479f877821c9f49ff49b8dec8bc Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Wed, 8 Jul 2026 13:54:09 +0200 Subject: [PATCH 33/75] refactor(telemetry): build empty metric objects via a default() factory Replace the bare None-defaulted fields with the Field(default=None) plus classmethod default() convention used elsewhere (e.g. message_bus Meta), and reference it from the parent through default_factory. The empty accounts/activity_24h fallbacks are now TelemetryAccountData.default() / TelemetryActivity24hData.default(), keeping the field set on the model as the single source of truth. Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/telemetry/models.py | 44 +++++++++++++++++----------- backend/infrahub/telemetry/tasks.py | 4 +-- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/backend/infrahub/telemetry/models.py b/backend/infrahub/telemetry/models.py index 6e9deafe340..3cdece9321f 100644 --- a/backend/infrahub/telemetry/models.py +++ b/backend/infrahub/telemetry/models.py @@ -1,4 +1,6 @@ -from pydantic import BaseModel +from typing import Self + +from pydantic import BaseModel, Field from .constants import InfrahubType @@ -14,23 +16,31 @@ class TelemetryBranchData(BaseModel): class TelemetryAccountData(BaseModel): - active: int | None = None - groups: int | None = None + active: int | None = Field(default=None) + groups: int | None = Field(default=None) + + @classmethod + def default(cls) -> Self: + return cls() class TelemetryActivity24hData(BaseModel): - logins: int | None = None - unique_logins: int | None = None - checks_started: int | None = None - checks_passed: int | None = None - checks_failed: int | None = None - artifacts_created: int | None = None - artifacts_updated: int | None = None - branches_created: int | None = None - branches_merged: int | None = None - branches_deleted: int | None = None - webhooks_fired_success: int | None = None - webhooks_fired_failure: int | None = None + logins: int | None = Field(default=None) + unique_logins: int | None = Field(default=None) + checks_started: int | None = Field(default=None) + checks_passed: int | None = Field(default=None) + checks_failed: int | None = Field(default=None) + artifacts_created: int | None = Field(default=None) + artifacts_updated: int | None = Field(default=None) + branches_created: int | None = Field(default=None) + branches_merged: int | None = Field(default=None) + branches_deleted: int | None = Field(default=None) + webhooks_fired_success: int | None = Field(default=None) + webhooks_fired_failure: int | None = Field(default=None) + + @classmethod + def default(cls) -> Self: + return cls() class TelemetrySchemaData(BaseModel): @@ -80,8 +90,8 @@ class TelemetryData(BaseModel): platform: str workers: TelemetryWorkerData branches: TelemetryBranchData - accounts: TelemetryAccountData - activity_24h: TelemetryActivity24hData + accounts: TelemetryAccountData = Field(default_factory=TelemetryAccountData.default) + activity_24h: TelemetryActivity24hData = Field(default_factory=TelemetryActivity24hData.default) features: dict[str, int] schema_info: TelemetrySchemaData database: TelemetryDatabaseData diff --git a/backend/infrahub/telemetry/tasks.py b/backend/infrahub/telemetry/tasks.py index c7d5b41f5b3..7610e12e6ea 100644 --- a/backend/infrahub/telemetry/tasks.py +++ b/backend/infrahub/telemetry/tasks.py @@ -151,8 +151,8 @@ async def gather_anonymous_telemetry_data( total=len(registry.branch), active=await safe_metric(active_branch_counter()), ), - accounts=accounts if accounts is not None else TelemetryAccountData(), - activity_24h=activity_24h if activity_24h is not None else TelemetryActivity24hData(), + accounts=accounts if accounts is not None else TelemetryAccountData.default(), + activity_24h=activity_24h if activity_24h is not None else TelemetryActivity24hData.default(), features=await gather_feature_information(), schema_info=await gather_schema_information(branch=default_branch), database=await gather_database_information(db=database), From b09aa5f3a5edfbfaa83c63c935e3ce4f6e139399 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Fri, 10 Jul 2026 17:46:49 +0200 Subject: [PATCH 34/75] test(telemetry): align test files with review conventions Rename test_datatabase.py -> test_database.py and test_degradation.py -> test_utils.py to mirror their source modules, import safe_metric from where it is defined (utils), reference the login event via AccountLoggedInEvent.event_name instead of a string literal, and trim restating docstrings/comments down to the non-obvious rationale. Co-Authored-By: Claude Opus 4.8 --- .../{test_datatabase.py => test_database.py} | 0 .../component/telemetry/test_task_manager.py | 3 +- .../tests/component/telemetry/test_tasks.py | 48 +++++-------------- .../{test_degradation.py => test_utils.py} | 2 +- .../quickstart.md | 4 +- 5 files changed, 16 insertions(+), 41 deletions(-) rename backend/tests/component/telemetry/{test_datatabase.py => test_database.py} (100%) rename backend/tests/unit/telemetry/{test_degradation.py => test_utils.py} (95%) diff --git a/backend/tests/component/telemetry/test_datatabase.py b/backend/tests/component/telemetry/test_database.py similarity index 100% rename from backend/tests/component/telemetry/test_datatabase.py rename to backend/tests/component/telemetry/test_database.py diff --git a/backend/tests/component/telemetry/test_task_manager.py b/backend/tests/component/telemetry/test_task_manager.py index b21db423308..6661f1fe560 100644 --- a/backend/tests/component/telemetry/test_task_manager.py +++ b/backend/tests/component/telemetry/test_task_manager.py @@ -10,6 +10,7 @@ from prefect.client.schemas.objects import State, StateType from prefect.events.schemas.events import Event, Resource +from infrahub.events.account_action import AccountLoggedInEvent from infrahub.events.artifact_action import ArtifactCreatedEvent, ArtifactUpdatedEvent from infrahub.events.branch_action import BranchCreatedEvent, BranchDeletedEvent, BranchMergedEvent from infrahub.events.utils import get_all_events @@ -28,7 +29,7 @@ if TYPE_CHECKING: from prefect.types import DateTime -LOGIN_EVENT_NAME = "infrahub.account.logged_in" +LOGIN_EVENT_NAME = AccountLoggedInEvent.event_name WEBHOOK_FLOW_NAME = WEBHOOK_PROCESS.name diff --git a/backend/tests/component/telemetry/test_tasks.py b/backend/tests/component/telemetry/test_tasks.py index 2c612749500..7732ac440ae 100644 --- a/backend/tests/component/telemetry/test_tasks.py +++ b/backend/tests/component/telemetry/test_tasks.py @@ -36,8 +36,7 @@ from tests.adapters.cache import MemoryCache from tests.adapters.message_bus import BusSimulator -# A calendar day far enough in the past that no test in any session has seeded an event into it; -# used to prove the genuine-empty -> 0 contract independent of shared Prefect server state. +# A far-past day no test ever seeds into: proves genuine-empty -> 0 without shared server state. _EMPTY_WINDOW_START = datetime(2000, 1, 1, tzinfo=UTC) _EMPTY_WINDOW_END = datetime(2000, 1, 2, tzinfo=UTC) @@ -70,7 +69,7 @@ async def _create_account_group(db: InfrahubDatabase, name: str) -> None: async def _store_snapshot(db: InfrahubDatabase, data: TelemetryData) -> TelemetrySnapshot: - """Persist a telemetry payload exactly as the send flow does, returning the saved snapshot.""" + """Persist a telemetry payload and return the saved snapshot.""" data_dict = data.model_dump(mode="json") checksum = hashlib.sha256(json.dumps(data_dict).encode()).hexdigest() snapshot = TelemetrySnapshot( @@ -125,13 +124,9 @@ async def telemetry_environment( register_core_models_schema: SchemaBranch, prefect_test_fixture: Generator[None, None, None], ) -> AsyncGenerator[InfrahubDatabase, None]: - """Wire the real in-memory collaborators the gather orchestrator depends on, no mocks. + """Wire the in-memory cache and message-bus adapters and a heartbeating component. - ``gather_anonymous_telemetry_data`` resolves a cache- and message-bus-backed component plus - a Prefect client. The cache and bus are the project's in-memory adapters (the sanctioned - test doubles), set through the dependency-override hooks the builders already honor; the - component type is set so a worker heartbeat key exists. Singletons are cleared on both sides - so the wiring does not leak into or out of this module. + Overrides are restored and singletons cleared on teardown so nothing leaks between modules. """ previous_cache = config.OVERRIDE.cache previous_message_bus = config.OVERRIDE.message_bus @@ -158,32 +153,27 @@ async def test_gather_full_payload_fields_present(telemetry_environment: Infrahu assert isinstance(data, TelemetryData) - # Accounts adoption metrics are present. assert data.accounts.active is not None assert data.accounts.groups is not None - # Active branch count is present. assert data.branches.active is not None - # Branch-correct managed-node count is present inside node_count. assert "corenode" in data.database.node_count assert data.database.node_count["corenode"] is not None - # User-defined-namespace node count is present inside node_count. assert "user" in data.database.node_count assert data.database.node_count["user"] is not None - # Every activity_24h field is present (an empty window is 0, not null). + # An empty window is 0, not null, so every field is populated. for field in _ACTIVITY_FIELDS: assert getattr(data.activity_24h, field) is not None, field async def _empty_window_activity_gatherer() -> TelemetryActivity24hData: - """Assemble activity_24h over a deterministically empty far-past window via real counters. + """Assemble activity_24h over a far-past window no test seeds, via the real counters. - Reuses the production windowed counters against a window no test ever seeds, so the sources - succeed but legitimately count nothing. This isolates the genuine-empty -> 0 contract from - the session-shared Prefect event store, which other modules populate around the live window. + The sources succeed but legitimately count nothing, isolating the empty -> 0 case from the + session-shared event store other modules populate around the live window. """ async with get_client(sync_client=False) as client: logins = await count_windowed_event.fn( @@ -218,17 +208,9 @@ async def _empty_window_activity_gatherer() -> TelemetryActivity24hData: async def test_gather_genuine_empty_activity_is_zero(telemetry_environment: InfrahubDatabase) -> None: - """A window with no seeded events yields 0 on the activity counts, never null. - - The activity source is gathered over a far-past window via the real production counters, so - the sources genuinely succeed with nothing to count. The result must be 0 (a successful - empty), never None (which would mean the source failed) — the core of the degradation - contract. - """ + """An empty window yields 0 on the activity counts, never null (a succeeded-but-empty source).""" data = await gather_anonymous_telemetry_data.fn(activity_gatherer=_empty_window_activity_gatherer) - # Each windowed count is a genuine empty: 0, distinguishing "succeeded with nothing to - # count" from "source failed" (which would be None). assert data.activity_24h.logins == 0 assert data.activity_24h.unique_logins == 0 assert data.activity_24h.webhooks_fired_success == 0 @@ -250,28 +232,20 @@ async def _boom_active_branches() -> int: async def test_gather_one_source_fails_others_populated_and_stored( telemetry_environment: InfrahubDatabase, ) -> None: - """One failing source nulls only its own field(s); the rest is populated and storable. - - The failing collaborator is a real coroutine that raises — the adapter pattern, not a mock. - The orchestrator isolates it through the degradation helper, so the accounts object comes - back fully null while every other metric (branches, corenode, the whole activity_24h - object) is still populated, and the resulting snapshot still persists to the repository. - """ + """One failing source nulls only its own fields; the rest is populated and still storable.""" db = telemetry_environment data = await gather_anonymous_telemetry_data.fn(account_gatherer=_boom_accounts) - # The failed source is null on both its fields. assert data.accounts.active is None assert data.accounts.groups is None - # Every other new metric is still populated. assert data.branches.active is not None assert data.database.node_count["corenode"] is not None for field in _ACTIVITY_FIELDS: assert getattr(data.activity_24h, field) is not None, field - # The payload is still storable end to end despite the failed source. + # The payload still persists end to end despite the failed source. snapshot = await _store_snapshot(db=db, data=data) repository = TelemetrySnapshotRepository(db=db) stored = await repository.get_list(limit=1) diff --git a/backend/tests/unit/telemetry/test_degradation.py b/backend/tests/unit/telemetry/test_utils.py similarity index 95% rename from backend/tests/unit/telemetry/test_degradation.py rename to backend/tests/unit/telemetry/test_utils.py index a77e3ce6a2e..e6b17fda6a8 100644 --- a/backend/tests/unit/telemetry/test_degradation.py +++ b/backend/tests/unit/telemetry/test_utils.py @@ -8,7 +8,7 @@ from __future__ import annotations -from infrahub.telemetry.tasks import safe_metric +from infrahub.telemetry.utils import safe_metric async def _raises() -> int: diff --git a/dev/specs/telemetry-collection-infp-589/quickstart.md b/dev/specs/telemetry-collection-infp-589/quickstart.md index 5597e51b243..7d7277cea6f 100644 --- a/dev/specs/telemetry-collection-infp-589/quickstart.md +++ b/dev/specs/telemetry-collection-infp-589/quickstart.md @@ -15,7 +15,7 @@ export DOCKER_HOST=unix://$HOME/.docker/run/docker.sock # component tests need Validates `null`-on-failure vs `0`-on-empty in isolation (SC-001). ```bash -uv run pytest backend/tests/unit/telemetry/test_degradation.py -q +uv run pytest backend/tests/unit/telemetry/test_utils.py -q ``` Expected: a failing metric coroutine yields `None`; a succeeding-but-empty coroutine yields `0`. @@ -23,7 +23,7 @@ Expected: a failing metric coroutine yields `None`; a succeeding-but-empty corou ## 2. Component: managed-node count exactness (SC-003) ```bash -uv run pytest backend/tests/component/telemetry/test_datatabase.py -q +uv run pytest backend/tests/component/telemetry/test_database.py -q ``` Expected: with a fixture of N managed nodes seeded via existing schema helpers, From 1c14c014cbe0c134a5851d1da8ca867cfbee3182 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Fri, 10 Jul 2026 18:43:33 +0200 Subject: [PATCH 35/75] chore: keep .specify/feature.json to match develop The branch had removed this develop-tracked file, so the PR showed a spurious deletion of an unrelated spec-kit state file. Restore develop's version so the PR leaves it untouched. Co-Authored-By: Claude Opus 4.8 --- .specify/feature.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .specify/feature.json diff --git a/.specify/feature.json b/.specify/feature.json new file mode 100644 index 00000000000..1be33c1f1c0 --- /dev/null +++ b/.specify/feature.json @@ -0,0 +1,3 @@ +{ + "feature_directory": "specs/001-entities-arch-migration" +} From ced0fe884efb121f20c3a555ca111db89213703a Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Tue, 14 Jul 2026 17:55:53 +0200 Subject: [PATCH 36/75] docs(telemetry): expand the FAQ disclosure to match the new payload The daily telemetry payload now also reports account/branch adoption counts, managed/user node counts, and a 24h activity summary; update the 'What telemetry does Infrahub send?' answer so the disclosure stays accurate. Co-Authored-By: Claude Opus 4.8 --- docs/docs/faq/faq.mdx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/docs/faq/faq.mdx b/docs/docs/faq/faq.mdx index 40ff3ff7509..826c8257a3c 100644 --- a/docs/docs/faq/faq.mdx +++ b/docs/docs/faq/faq.mdx @@ -216,9 +216,11 @@ export INFRAHUB_TELEMETRY_OPTOUT=true The following information is included in telemetry: -- Infrahub version -- Platform information -- Anonymous counters about the graph and the schema +- Infrahub version, deployment type, and platform (Python / OS / architecture) +- Anonymous counters about the graph, schema, and features in use +- Adoption counts: active accounts, account groups, and open branches +- Database, worker, and infrastructure statistics +- Daily activity over the previous day: logins, validation checks, artifact generation, branch operations, and webhook deliveries This information is used as aggregated analysis to better understand what and how to improve the project. All information collected is anonymous and the implementation is open source on GitHub. From 72de0192965134086b73a2fec1bcdf3b283bc44b Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Tue, 14 Jul 2026 17:56:17 +0200 Subject: [PATCH 37/75] docs: apply review lessons harvested from #9805 Bring the doc improvements (query-pattern knowledge, python guidelines, AGENTS routing) down from the stacked harvesting-review branch so the review context lives alongside the telemetry work. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 4 ++-- backend/AGENTS.md | 5 +++-- dev/guidelines/backend/python.md | 29 ++++++++++++++++++++++++++ dev/knowledge/backend/query-pattern.md | 22 +++++++++++++------ 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0ac23acd288..a547c707689 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,12 +19,12 @@ Style: be direct and substantive. No filler, preamble, or pleasantries. Challeng - `backend/` – Python backend (FastAPI, GraphQL, core logic) - see [backend/AGENTS.md](backend/AGENTS.md) - `frontend/app/` – React frontend - see [frontend/app/AGENTS.md](frontend/app/AGENTS.md) -- `docs/` – Docusaurus documentation - see [docs/AGENTS.md](docs/AGENTS.md) +- `docs/` – External customers documentation by Docusaurus, deployed at https://docs.infrahub.app/ - see [docs/AGENTS.md](docs/AGENTS.md) +- `dev/` – Internal developer documentation - see [dev/README.md](dev/README.md) - `python_sdk/` – Python SDK (Git submodule) - `tasks/` – Invoke task definitions - `schema/` – JSON/GraphQL schema definitions - `changelog/` – Towncrier changelog fragments -- `dev/` – Internal developer documentation - see [dev/README.md](dev/README.md) ## Commands diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 68efef569fb..6bc52774ae5 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -87,18 +87,19 @@ See `dev/knowledge/backend/testing.md` for detailed testing infrastructure docum ### Knowledge (How the system works, load the knowledge base when working on relevant tasks) - `dev/knowledge/backend/architecture.md` - Backend architecture overview +- `dev/knowledge/backend/query-pattern.md` - Query class pattern and how database access works. Load when writing or changing any database read/write, doing schema lookups, counting nodes, or choosing between the `db` proxy and the `registry`. - `dev/knowledge/backend/testing.md` - Testing infrastructure and patterns - `dev/knowledge/backend/authentication.md` - Authentication flow, SSO group resolution, auto-create groups - `dev/knowledge/backend/branch-status.md` - Branch status enforcement (`BranchStatusChecker`, middleware allowlists, permission integration) - `dev/knowledge/backend/events.md` - Events system -- `dev/knowledge/backend/async-tasks.md` - Asynchronous tasks (Prefect) +- `dev/knowledge/backend/async-tasks.md` - How Prefect tasks/flows work. Load when writing or changing a `@task`/`@flow`, including logging and workflow-name conventions. - `dev/knowledge/backend/message-bus.md` - Message bus system - `dev/knowledge/backend/telemetry.md` - Anonymous usage telemetry (categories, windowing, retention, degradation) ### Guides (How to do X) - `dev/guides/backend/creating-events.md` - Creating new events -- `dev/guides/backend/creating-async-tasks.md` - Creating async tasks +- `dev/guides/backend/creating-async-tasks.md` - How to create an async task, with a pre-submit checklist. Load when adding a `@task`/`@flow`. - `dev/guides/backend/creating-messages.md` - Creating message bus messages ### ADRs (Why we decided) diff --git a/dev/guidelines/backend/python.md b/dev/guidelines/backend/python.md index c4a6eace6c8..fb25c89f64e 100644 --- a/dev/guidelines/backend/python.md +++ b/dev/guidelines/backend/python.md @@ -173,6 +173,35 @@ branch_data = {"name": "feature-x", "description": None} branch_data = BranchCreateInput(name="feature-x") ``` +### Use dict.get() for Default Values + +Prefer `dict.get(key, default)` over an existence check or `try/except` when reading a key that may be missing. It is more concise and avoids the cost of raising and catching `KeyError`: + +```python +config: dict[str, int] = {"timeout": 30} + +# ❌ Bad - verbose existence check +if "retries" in config: + retries = config["retries"] +else: + retries = 3 + +# ❌ Bad - exception handling for an expected-missing key +try: + retries = config["retries"] +except KeyError: + retries = 3 + +# ✅ Good - get() with an explicit default +retries = config.get("retries", 3) +``` + +Guidelines: + +- `get()` without a second argument returns `None` for missing keys. +- Chain for nested access: `config.get("db", {}).get("host", "localhost")`. +- Use `setdefault()` when the default is a mutable object you intend to build up: `cache.setdefault("results", []).append(42)`. + ## Docstrings (Google-style) All public functions and classes must have Google-style docstrings: diff --git a/dev/knowledge/backend/query-pattern.md b/dev/knowledge/backend/query-pattern.md index f285061a727..061155f7c88 100644 --- a/dev/knowledge/backend/query-pattern.md +++ b/dev/knowledge/backend/query-pattern.md @@ -4,6 +4,21 @@ All database access in Infrahub goes through Query classes that encapsulate Cypher queries with proper parameterization, branch-awareness, and temporal versioning. Queries return typed dataclass results for type safety and clear API contracts. +## Accessing schema: prefer `db.schema` over `registry` + +Access schema through the `db` proxy, not the `registry`. `db.schema` resolves against the branch and time of the operation, staying temporally correct; `registry.schema` does not. + +```python +# ✅ Good - temporally correct +schema_branch = db.schema.get_schema_branch(name=branch.name) +schema = db.schema.get(name="MyNode", branch=branch) + +# ❌ Avoid - loses temporal flexibility +schema = registry.schema.get(name="MyNode") +``` + +The `registry` stays correct for in-memory data where the alternative would be a real performance regression — for example, listing open branches from `registry.branch` instead of issuing a database query. This is a preference for new code; do not sweep existing `registry` call sites as part of an unrelated change. + ## Query Lifecycle ### Initialization @@ -466,12 +481,7 @@ Specialized base classes for different domains: **Database Object in Initialization:** The `InfrahubDatabase` object is passed during initialization for: -1. **Schema access via database proxy:** Enables temporal queries using previous schema versions - - ```python - schema = db.schema.get(name="MyNode", branch=branch) # Good - schema = registry.schema.get(name="MyNode") # Avoid: loses temporal flexibility - ``` +1. **Schema access via database proxy:** Enables temporal queries using previous schema versions — see [Accessing schema: prefer `db.schema` over `registry`](#accessing-schema-prefer-dbschema-over-registry). 2. **Database type abstraction:** Contains database-specific functions From 901edd29ef5c7bef1fb215b1828109bcf18a08b3 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Tue, 14 Jul 2026 18:24:10 +0200 Subject: [PATCH 38/75] fix(telemetry): address review comments - count_user_nodes reads the schema branch from db.schema, not the registry singleton - count_windowed_event uses bucket.get("count", 0) so a bucket without a count contributes 0 - clarify that count_flow_runs (server-side) is used deliberately, read_flow_runs is not - count_corenode docstring states the count is on the default branch at gather time - drop the stray degradation-helper sentence from count_active_branches Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/telemetry/database.py | 8 ++++++-- backend/infrahub/telemetry/task_manager.py | 5 +++-- backend/infrahub/telemetry/tasks.py | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/backend/infrahub/telemetry/database.py b/backend/infrahub/telemetry/database.py index d21c8f8eed0..4d174a6bcad 100644 --- a/backend/infrahub/telemetry/database.py +++ b/backend/infrahub/telemetry/database.py @@ -52,7 +52,11 @@ async def get_system_info(db: InfrahubDatabase) -> TelemetryDatabaseSystemInfoDa async def count_corenode(db: InfrahubDatabase) -> int: - """Count managed nodes via the branch/temporal-correct path, not a raw label tally.""" + """Count managed nodes visible on the default branch at gather time. + + Uses the branch-safe count path so the total matches what the product reports for the + default branch, rather than a raw vertex/label tally. + """ return await NodeManager.count(db=db, schema=InfrahubKind.NODE, branch=registry.get_branch_from_registry()) @@ -63,7 +67,7 @@ async def count_user_nodes(db: InfrahubDatabase) -> int: would otherwise let ``user`` exceed ``corenode``. """ default_branch = registry.get_branch_from_registry() - schema_branch = registry.schema.get_schema_branch(name=default_branch.name) + schema_branch = db.schema.get_schema_branch(name=default_branch.name) user_namespaces = [namespace.name for namespace in schema_branch.get_namespaces() if namespace.user_editable] total = 0 for node_schema in schema_branch.get_schemas_for_namespaces(namespaces=user_namespaces): diff --git a/backend/infrahub/telemetry/task_manager.py b/backend/infrahub/telemetry/task_manager.py index 38204be31d7..efae77d556e 100644 --- a/backend/infrahub/telemetry/task_manager.py +++ b/backend/infrahub/telemetry/task_manager.py @@ -89,7 +89,7 @@ async def count_windowed_event( """Count events of one name that occurred within ``[window_start, window_end)``.""" payload = _windowed_event_filter(event_name=event_name, window_start=window_start, window_end=window_end) buckets = await _post_count_by(client=client, path="/events/count-by/event", payload=payload) - return sum(bucket["count"] for bucket in buckets) + return sum(bucket.get("count", 0) for bucket in buckets) @task(name="telemetry-gather-windowed-unique", task_run_name="Gather Windowed Unique Count", cache_policy=NONE) @@ -123,7 +123,8 @@ def runs_in_states(states: list[StateType]) -> FlowRunFilter: state=FlowRunFilterState(type=FlowRunFilterStateType(any_=states)), ) - # Count server-side: read_flow_runs caps at the API page size and would undercount a busy day. + # count_flow_runs returns a server-side count. read_flow_runs is deliberately not used here: + # it pages at the API limit and would undercount a busy day. success = await client.count_flow_runs( flow_filter=flow_filter, flow_run_filter=runs_in_states([StateType.COMPLETED]) ) diff --git a/backend/infrahub/telemetry/tasks.py b/backend/infrahub/telemetry/tasks.py index 7610e12e6ea..3c22322aa71 100644 --- a/backend/infrahub/telemetry/tasks.py +++ b/backend/infrahub/telemetry/tasks.py @@ -102,7 +102,7 @@ async def count_active_branches() -> int: """Count open non-system branches. Registry members minus the default and global branches (closed branches are evicted from - the registry). Async only so it composes with the degradation helper. + the registry). """ return len([branch for branch in registry.branch.values() if not branch.is_default and not branch.is_global]) From 21383dd642c8c236667a3409bd0f6cb2ad782580 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Tue, 14 Jul 2026 19:25:07 +0200 Subject: [PATCH 39/75] refactor(telemetry): build the payload gatherer via dependency injection Replace the callable-injection function with an AnonymousTelemetryGatherer that takes its account, activity, and active-branch collaborators as constructor dependencies (each behind a Protocol), wired by build_anonymous_telemetry_gatherer at the flow entry point. Tests inject protocol-satisfying doubles instead of callables. Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/telemetry/tasks.py | 146 ++++++++++++------ .../tests/component/telemetry/test_tasks.py | 126 +++++++++------ backend/tests/unit/telemetry/test_workflow.py | 10 +- 3 files changed, 186 insertions(+), 96 deletions(-) diff --git a/backend/infrahub/telemetry/tasks.py b/backend/infrahub/telemetry/tasks.py index 3c22322aa71..52bd4a49f3a 100644 --- a/backend/infrahub/telemetry/tasks.py +++ b/backend/infrahub/telemetry/tasks.py @@ -2,8 +2,7 @@ import json import platform import time -from collections.abc import Awaitable, Callable -from typing import Any +from typing import Any, Protocol from prefect import flow, task from prefect.cache_policies import NONE @@ -16,6 +15,7 @@ from infrahub.core.constants import AccountStatus, InfrahubKind from infrahub.core.manager import NodeManager from infrahub.database import InfrahubDatabase +from infrahub.services.component import InfrahubComponent from infrahub.workers.dependencies import get_component, get_database, get_http from .constants import ( @@ -107,61 +107,108 @@ async def count_active_branches() -> int: return len([branch for branch in registry.branch.values() if not branch.is_default and not branch.is_global]) -async def _default_activity_24h_gatherer() -> TelemetryActivity24hData: - """Open a Prefect client and assemble the windowed 24h activity metrics.""" - async with get_prefect_client(sync_client=False) as prefect_client: - return await gather_activity_24h(client=prefect_client) +class AccountGatherer(Protocol): + async def gather(self) -> TelemetryAccountData: ... -@task(name="telemetry-gather-data", task_run_name="Gather Anonynous Data", cache_policy=NONE) -async def gather_anonymous_telemetry_data( - account_gatherer: Callable[[InfrahubDatabase], Awaitable[TelemetryAccountData]] = gather_account_information, - activity_gatherer: Callable[[], Awaitable[TelemetryActivity24hData]] = _default_activity_24h_gatherer, - active_branch_counter: Callable[[], Awaitable[int]] = count_active_branches, -) -> TelemetryData: - """Assemble the full telemetry payload, isolating every metric source. +class ActivityGatherer(Protocol): + async def gather(self) -> TelemetryActivity24hData: ... - Each source is gathered through the degradation helper, so one failing source nulls only - its own field(s) while the rest is still built. The ``*_gatherer`` / ``*_counter`` params - are injection seams that let the per-source isolation be tested with a real failing - collaborator instead of patching. + +class ActiveBranchCounter(Protocol): + async def count(self) -> int: ... + + +class DefaultAccountGatherer: + def __init__(self, db: InfrahubDatabase) -> None: + self.db = db + + async def gather(self) -> TelemetryAccountData: + return await gather_account_information(db=self.db) + + +class DefaultActivityGatherer: + async def gather(self) -> TelemetryActivity24hData: + async with get_prefect_client(sync_client=False) as prefect_client: + return await gather_activity_24h(client=prefect_client) + + +class DefaultActiveBranchCounter: + async def count(self) -> int: + return await count_active_branches() + + +class AnonymousTelemetryGatherer: + """Assemble the full telemetry payload from injected, independently-degrading sources. + + The account, activity, and active-branch collaborators are injected so each can be swapped + for a failing double in tests. Every one is isolated, so a single failing source nulls only + its own field(s) while the rest of the payload is still built. """ - start_time = time.time() - default_branch = registry.get_branch_from_registry() - database = await get_database() - component = await get_component() - workers = await component.list_workers(branch=default_branch.name, schema_hash=False) + def __init__( + self, + *, + database: InfrahubDatabase, + component: InfrahubComponent, + account_gatherer: AccountGatherer, + activity_gatherer: ActivityGatherer, + active_branch_counter: ActiveBranchCounter, + ) -> None: + self.database = database + self.component = component + self.account_gatherer = account_gatherer + self.activity_gatherer = activity_gatherer + self.active_branch_counter = active_branch_counter + + async def gather(self) -> TelemetryData: + start_time = time.time() + + default_branch = registry.get_branch_from_registry() + workers = await self.component.list_workers(branch=default_branch.name, schema_hash=False) + + accounts = await safe_metric(self.account_gatherer.gather()) + activity_24h = await safe_metric(self.activity_gatherer.gather()) + + data = TelemetryData( + deployment_id=registry.id, + execution_time=None, + infrahub_version=__version__, + infrahub_type=determine_infrahub_type(), + python_version=platform.python_version(), + platform=platform.machine(), + workers=TelemetryWorkerData( + total=len(workers), + active=len([w for w in workers if w.active]), + ), + branches=TelemetryBranchData( + total=len(registry.branch), + active=await safe_metric(self.active_branch_counter.count()), + ), + accounts=accounts if accounts is not None else TelemetryAccountData.default(), + activity_24h=activity_24h if activity_24h is not None else TelemetryActivity24hData.default(), + features=await gather_feature_information(), + schema_info=await gather_schema_information(branch=default_branch), + database=await gather_database_information(db=self.database), + prefect=await gather_prefect_information(), + ) - accounts = await safe_metric(account_gatherer(database)) - activity_24h = await safe_metric(activity_gatherer()) + data.execution_time = time.time() - start_time - data = TelemetryData( - deployment_id=registry.id, - execution_time=None, - infrahub_version=__version__, - infrahub_type=determine_infrahub_type(), - python_version=platform.python_version(), - platform=platform.machine(), - workers=TelemetryWorkerData( - total=len(workers), - active=len([w for w in workers if w.active]), - ), - branches=TelemetryBranchData( - total=len(registry.branch), - active=await safe_metric(active_branch_counter()), - ), - accounts=accounts if accounts is not None else TelemetryAccountData.default(), - activity_24h=activity_24h if activity_24h is not None else TelemetryActivity24hData.default(), - features=await gather_feature_information(), - schema_info=await gather_schema_information(branch=default_branch), - database=await gather_database_information(db=database), - prefect=await gather_prefect_information(), - ) + return data - data.execution_time = time.time() - start_time - return data +async def build_anonymous_telemetry_gatherer() -> AnonymousTelemetryGatherer: + """Wire the telemetry gatherer with its real collaborators.""" + database = await get_database() + component = await get_component() + return AnonymousTelemetryGatherer( + database=database, + component=component, + account_gatherer=DefaultAccountGatherer(db=database), + activity_gatherer=DefaultActivityGatherer(), + active_branch_counter=DefaultActiveBranchCounter(), + ) @task(name="telemetry-post-data", task_run_name="Upload data", retries=5, cache_policy=NONE) @@ -176,7 +223,8 @@ async def send_telemetry_push() -> None: log = get_run_logger() log.info("Gathering anonymous telemetry data...") - data = await gather_anonymous_telemetry_data() + gatherer = await build_anonymous_telemetry_gatherer() + data = await gatherer.gather() data_dict = data.model_dump(mode="json") checksum = hashlib.sha256(json.dumps(data_dict).encode()).hexdigest() log.info(f"Anonymous usage telemetry gathered in {data.execution_time} seconds.") diff --git a/backend/tests/component/telemetry/test_tasks.py b/backend/tests/component/telemetry/test_tasks.py index 7732ac440ae..afc3dd3040c 100644 --- a/backend/tests/component/telemetry/test_tasks.py +++ b/backend/tests/component/telemetry/test_tasks.py @@ -28,11 +28,24 @@ count_windowed_unique_resources, ) from infrahub.telemetry.tasks import ( + AccountGatherer, + ActiveBranchCounter, + ActivityGatherer, + AnonymousTelemetryGatherer, + DefaultAccountGatherer, + DefaultActiveBranchCounter, + DefaultActivityGatherer, + build_anonymous_telemetry_gatherer, count_active_branches, gather_account_information, - gather_anonymous_telemetry_data, ) -from infrahub.workers.dependencies import build_component, clear_singletons, set_component_type +from infrahub.workers.dependencies import ( + build_component, + clear_singletons, + get_component, + get_database, + set_component_type, +) from tests.adapters.cache import MemoryCache from tests.adapters.message_bus import BusSimulator @@ -147,9 +160,27 @@ async def telemetry_environment( clear_singletons() +async def _build_gatherer( + account_gatherer: AccountGatherer | None = None, + activity_gatherer: ActivityGatherer | None = None, + active_branch_counter: ActiveBranchCounter | None = None, +) -> AnonymousTelemetryGatherer: + """Build the gatherer with real collaborators, overriding any one with an injected double.""" + database = await get_database() + component = await get_component() + return AnonymousTelemetryGatherer( + database=database, + component=component, + account_gatherer=account_gatherer or DefaultAccountGatherer(db=database), + activity_gatherer=activity_gatherer or DefaultActivityGatherer(), + active_branch_counter=active_branch_counter or DefaultActiveBranchCounter(), + ) + + async def test_gather_full_payload_fields_present(telemetry_environment: InfrahubDatabase) -> None: """A healthy gather populates every new field on the payload (presence, not exact values).""" - data = await gather_anonymous_telemetry_data.fn() + gatherer = await build_anonymous_telemetry_gatherer() + data = await gatherer.gather() assert isinstance(data, TelemetryData) @@ -169,47 +200,50 @@ async def test_gather_full_payload_fields_present(telemetry_environment: Infrahu assert getattr(data.activity_24h, field) is not None, field -async def _empty_window_activity_gatherer() -> TelemetryActivity24hData: +class EmptyWindowActivityGatherer: """Assemble activity_24h over a far-past window no test seeds, via the real counters. The sources succeed but legitimately count nothing, isolating the empty -> 0 case from the session-shared event store other modules populate around the live window. """ - async with get_client(sync_client=False) as client: - logins = await count_windowed_event.fn( - client=client, - event_name=AccountLoggedInEvent.event_name, - window_start=_EMPTY_WINDOW_START, - window_end=_EMPTY_WINDOW_END, - ) - unique_logins = await count_windowed_unique_resources.fn( - client=client, - event_name=AccountLoggedInEvent.event_name, - window_start=_EMPTY_WINDOW_START, - window_end=_EMPTY_WINDOW_END, - ) - webhook_success, webhook_failure = await count_webhook_runs.fn( - client=client, window_start=_EMPTY_WINDOW_START, window_end=_EMPTY_WINDOW_END + + async def gather(self) -> TelemetryActivity24hData: + async with get_client(sync_client=False) as client: + logins = await count_windowed_event.fn( + client=client, + event_name=AccountLoggedInEvent.event_name, + window_start=_EMPTY_WINDOW_START, + window_end=_EMPTY_WINDOW_END, + ) + unique_logins = await count_windowed_unique_resources.fn( + client=client, + event_name=AccountLoggedInEvent.event_name, + window_start=_EMPTY_WINDOW_START, + window_end=_EMPTY_WINDOW_END, + ) + webhook_success, webhook_failure = await count_webhook_runs.fn( + client=client, window_start=_EMPTY_WINDOW_START, window_end=_EMPTY_WINDOW_END + ) + return TelemetryActivity24hData( + logins=logins, + unique_logins=unique_logins, + checks_started=0, + checks_passed=0, + checks_failed=0, + artifacts_created=0, + artifacts_updated=0, + branches_created=0, + branches_merged=0, + branches_deleted=0, + webhooks_fired_success=webhook_success, + webhooks_fired_failure=webhook_failure, ) - return TelemetryActivity24hData( - logins=logins, - unique_logins=unique_logins, - checks_started=0, - checks_passed=0, - checks_failed=0, - artifacts_created=0, - artifacts_updated=0, - branches_created=0, - branches_merged=0, - branches_deleted=0, - webhooks_fired_success=webhook_success, - webhooks_fired_failure=webhook_failure, - ) async def test_gather_genuine_empty_activity_is_zero(telemetry_environment: InfrahubDatabase) -> None: """An empty window yields 0 on the activity counts, never null (a succeeded-but-empty source).""" - data = await gather_anonymous_telemetry_data.fn(activity_gatherer=_empty_window_activity_gatherer) + gatherer = await _build_gatherer(activity_gatherer=EmptyWindowActivityGatherer()) + data = await gatherer.gather() assert data.activity_24h.logins == 0 assert data.activity_24h.unique_logins == 0 @@ -217,16 +251,19 @@ async def test_gather_genuine_empty_activity_is_zero(telemetry_environment: Infr assert data.activity_24h.webhooks_fired_failure == 0 -async def _boom_accounts(_db: InfrahubDatabase) -> TelemetryAccountData: - raise RuntimeError("accounts source unavailable") +class BoomAccountGatherer: + async def gather(self) -> TelemetryAccountData: + raise RuntimeError("accounts source unavailable") -async def _boom_activity() -> TelemetryActivity24hData: - raise RuntimeError("activity source unavailable") +class BoomActivityGatherer: + async def gather(self) -> TelemetryActivity24hData: + raise RuntimeError("activity source unavailable") -async def _boom_active_branches() -> int: - raise RuntimeError("branch source unavailable") +class BoomActiveBranchCounter: + async def count(self) -> int: + raise RuntimeError("branch source unavailable") async def test_gather_one_source_fails_others_populated_and_stored( @@ -235,7 +272,8 @@ async def test_gather_one_source_fails_others_populated_and_stored( """One failing source nulls only its own fields; the rest is populated and still storable.""" db = telemetry_environment - data = await gather_anonymous_telemetry_data.fn(account_gatherer=_boom_accounts) + gatherer = await _build_gatherer(account_gatherer=BoomAccountGatherer()) + data = await gatherer.gather() assert data.accounts.active is None assert data.accounts.groups is None @@ -257,7 +295,8 @@ async def test_gather_activity_source_fails_only_activity_null( telemetry_environment: InfrahubDatabase, ) -> None: """A failing activity source nulls the whole activity_24h object, leaving the rest intact.""" - data = await gather_anonymous_telemetry_data.fn(activity_gatherer=_boom_activity) + gatherer = await _build_gatherer(activity_gatherer=BoomActivityGatherer()) + data = await gatherer.gather() for field in _ACTIVITY_FIELDS: assert getattr(data.activity_24h, field) is None, field @@ -272,7 +311,8 @@ async def test_gather_branch_source_fails_only_branch_active_null( telemetry_environment: InfrahubDatabase, ) -> None: """A failing active-branch counter nulls only branches.active; branches.total is intact.""" - data = await gather_anonymous_telemetry_data.fn(active_branch_counter=_boom_active_branches) + gatherer = await _build_gatherer(active_branch_counter=BoomActiveBranchCounter()) + data = await gatherer.gather() assert data.branches.active is None # branches.total is computed directly from the registry and is never nullable. diff --git a/backend/tests/unit/telemetry/test_workflow.py b/backend/tests/unit/telemetry/test_workflow.py index 015d0e0e913..f884ce30e3c 100644 --- a/backend/tests/unit/telemetry/test_workflow.py +++ b/backend/tests/unit/telemetry/test_workflow.py @@ -36,12 +36,14 @@ def telemetry_mocks() -> Iterator[dict[str, Any]]: """Combined fixture providing all mocks needed for telemetry workflow tests.""" repo = create_autospec(TelemetrySnapshotRepository, spec_set=True, instance=True) repo.save.return_value = None + gatherer = MagicMock() + gatherer.gather = AsyncMock(return_value=_build_telemetry_data_mock()) with ( patch( - "infrahub.telemetry.tasks.gather_anonymous_telemetry_data", + "infrahub.telemetry.tasks.build_anonymous_telemetry_gatherer", new_callable=AsyncMock, - return_value=_build_telemetry_data_mock(), - ) as mock_gather, + return_value=gatherer, + ), patch("infrahub.telemetry.tasks.post_telemetry_data", new_callable=AsyncMock) as mock_post, patch("infrahub.telemetry.tasks.get_database", new_callable=AsyncMock, return_value=MagicMock()), patch("infrahub.telemetry.tasks.TelemetrySnapshotRepository", return_value=repo) as mock_repo_cls, @@ -49,7 +51,7 @@ def telemetry_mocks() -> Iterator[dict[str, Any]]: ): mock_registry.id = "dep-123" yield { - "gather": mock_gather, + "gather": gatherer.gather, "post": mock_post, "repo": repo, "repo_cls": mock_repo_cls, From 2994d87289db7973cd2a5fced4e52b313e4a9366 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Tue, 14 Jul 2026 20:09:44 +0200 Subject: [PATCH 40/75] chore(telemetry): address cubic review follow-ups on the DI refactor - update the collection-flow diagram to the AnonymousTelemetryGatherer path (the old gather_anonymous_telemetry_data function was removed in the DI refactor) - autospec the gatherer mock in the workflow test so a renamed/removed gather method is caught instead of silently passing Co-Authored-By: Claude Opus 4.8 --- backend/tests/unit/telemetry/test_workflow.py | 6 +++--- dev/knowledge/backend/telemetry.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/tests/unit/telemetry/test_workflow.py b/backend/tests/unit/telemetry/test_workflow.py index f884ce30e3c..e8614612667 100644 --- a/backend/tests/unit/telemetry/test_workflow.py +++ b/backend/tests/unit/telemetry/test_workflow.py @@ -7,7 +7,7 @@ import pytest from infrahub.telemetry.repository import TelemetrySnapshotRepository -from infrahub.telemetry.tasks import send_telemetry_push +from infrahub.telemetry.tasks import AnonymousTelemetryGatherer, send_telemetry_push if TYPE_CHECKING: from collections.abc import Iterator @@ -36,8 +36,8 @@ def telemetry_mocks() -> Iterator[dict[str, Any]]: """Combined fixture providing all mocks needed for telemetry workflow tests.""" repo = create_autospec(TelemetrySnapshotRepository, spec_set=True, instance=True) repo.save.return_value = None - gatherer = MagicMock() - gatherer.gather = AsyncMock(return_value=_build_telemetry_data_mock()) + gatherer = create_autospec(AnonymousTelemetryGatherer, spec_set=True, instance=True) + gatherer.gather.return_value = _build_telemetry_data_mock() with ( patch( "infrahub.telemetry.tasks.build_anonymous_telemetry_gatherer", diff --git a/dev/knowledge/backend/telemetry.md b/dev/knowledge/backend/telemetry.md index cda72ffa050..2191118c623 100644 --- a/dev/knowledge/backend/telemetry.md +++ b/dev/knowledge/backend/telemetry.md @@ -14,7 +14,7 @@ minute) gathers the payload, stores it as a `TelemetrySnapshot`, then conditiona ```text anonymous_telemetry_send (daily) - └─ gather_anonymous_telemetry_data() → TelemetryData + └─ build_anonymous_telemetry_gatherer() → AnonymousTelemetryGatherer.gather() → TelemetryData └─ TelemetrySnapshot.save() ← ALWAYS stored locally first └─ opted out? → mark "skipped" opted in? → POST to endpoint → mark "sent" / "failed" From a552a33ecfdc6fb2c145bd79229dc0b10cabb971 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Wed, 15 Jul 2026 09:47:36 +0200 Subject: [PATCH 41/75] docs(backend): prefer injecting SchemaManager over the global registry Rework the schema-access guidance into inject > db.schema > registry, note the global-registry coupling and the get_schema_manager() target, and reframe the registry exception as hot-path vs cold-path so it stays consistent with cold paths querying the database. Co-Authored-By: Claude Opus 4.8 --- dev/knowledge/backend/query-pattern.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/dev/knowledge/backend/query-pattern.md b/dev/knowledge/backend/query-pattern.md index 061155f7c88..b8ae46e189f 100644 --- a/dev/knowledge/backend/query-pattern.md +++ b/dev/knowledge/backend/query-pattern.md @@ -4,20 +4,22 @@ All database access in Infrahub goes through Query classes that encapsulate Cypher queries with proper parameterization, branch-awareness, and temporal versioning. Queries return typed dataclass results for type safety and clear API contracts. -## Accessing schema: prefer `db.schema` over `registry` +## Accessing schema: inject `SchemaManager`, else `db.schema`, never `registry` -Access schema through the `db` proxy, not the `registry`. `db.schema` resolves against the branch and time of the operation, staying temporally correct; `registry.schema` does not. +The `registry` is a legitimate in-memory cache (schemas, branches, node classes) but a global singleton imported across ~150 modules — a source of circular imports and coupling. Encapsulate and inject it instead. Preference order: -```python -# ✅ Good - temporally correct -schema_branch = db.schema.get_schema_branch(name=branch.name) -schema = db.schema.get(name="MyNode", branch=branch) +1. **Inject `SchemaManager`** into the constructor, built at the entry point (task/flow/API/CLI) — never import `registry` inside a component. +2. **`db.schema`** when injection isn't practical — temporally correct against the operation's branch/time; `registry.schema` is not. +3. **`registry.schema`** — avoid. -# ❌ Avoid - loses temporal flexibility -schema = registry.schema.get(name="MyNode") +```python +class MyComponent: + def __init__(self, schema_manager: SchemaManager) -> None: ... ``` -The `registry` stays correct for in-memory data where the alternative would be a real performance regression — for example, listing open branches from `registry.branch` instead of issuing a database query. This is a preference for new code; do not sweep existing `registry` call sites as part of an unrelated change. +Components already accept `schema_manager`; entry points still pass `registry.schema`, concentrating the access at the boundary on the way to a `get_schema_manager()` accessor and retiring `registry` imports. + +Exception: `registry` stays for hot (per-request) in-memory reads where a DB round-trip is a real regression (e.g. `registry.branch`); cold paths (daily tasks) use the DB. New-code preference — don't sweep existing call sites. ## Query Lifecycle @@ -481,7 +483,7 @@ Specialized base classes for different domains: **Database Object in Initialization:** The `InfrahubDatabase` object is passed during initialization for: -1. **Schema access via database proxy:** Enables temporal queries using previous schema versions — see [Accessing schema: prefer `db.schema` over `registry`](#accessing-schema-prefer-dbschema-over-registry). +1. **Schema access via database proxy:** Enables temporal queries using previous schema versions — see [Accessing schema: inject `SchemaManager`, else `db.schema`, never `registry`](#accessing-schema-inject-schemamanager-else-dbschema-never-registry). 2. **Database type abstraction:** Contains database-specific functions From b0f83fbc545a33395200f4ab0b5c7c858b8e308f Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Wed, 15 Jul 2026 09:47:37 +0200 Subject: [PATCH 42/75] refactor(telemetry): count active branches from the database, not the registry count_active_branches now uses Branch.get_list(exclude_global, exclude_default) instead of reading registry.branch. The daily gather is a cold path, so the database round-trip is the consistent choice and doesn't depend on the worker's in-memory registry being fully populated. Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/telemetry/tasks.py | 18 +++++++++--------- .../tests/component/telemetry/test_tasks.py | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/backend/infrahub/telemetry/tasks.py b/backend/infrahub/telemetry/tasks.py index 52bd4a49f3a..3a23d6789f6 100644 --- a/backend/infrahub/telemetry/tasks.py +++ b/backend/infrahub/telemetry/tasks.py @@ -98,13 +98,10 @@ async def gather_account_information(db: InfrahubDatabase) -> TelemetryAccountDa return TelemetryAccountData(active=active, groups=groups) -async def count_active_branches() -> int: - """Count open non-system branches. - - Registry members minus the default and global branches (closed branches are evicted from - the registry). - """ - return len([branch for branch in registry.branch.values() if not branch.is_default and not branch.is_global]) +async def count_active_branches(db: InfrahubDatabase) -> int: + """Count open non-system branches, excluding the default and global branches.""" + branches = await Branch.get_list(db=db, exclude_global=True, exclude_default=True) + return len(branches) class AccountGatherer(Protocol): @@ -134,8 +131,11 @@ async def gather(self) -> TelemetryActivity24hData: class DefaultActiveBranchCounter: + def __init__(self, db: InfrahubDatabase) -> None: + self.db = db + async def count(self) -> int: - return await count_active_branches() + return await count_active_branches(db=self.db) class AnonymousTelemetryGatherer: @@ -207,7 +207,7 @@ async def build_anonymous_telemetry_gatherer() -> AnonymousTelemetryGatherer: component=component, account_gatherer=DefaultAccountGatherer(db=database), activity_gatherer=DefaultActivityGatherer(), - active_branch_counter=DefaultActiveBranchCounter(), + active_branch_counter=DefaultActiveBranchCounter(db=database), ) diff --git a/backend/tests/component/telemetry/test_tasks.py b/backend/tests/component/telemetry/test_tasks.py index afc3dd3040c..4a2158ee9e3 100644 --- a/backend/tests/component/telemetry/test_tasks.py +++ b/backend/tests/component/telemetry/test_tasks.py @@ -128,7 +128,7 @@ async def test_active_branches_excludes_default_and_global( assert any(branch.is_default for branch in registry.branch.values()) assert any(branch.is_global for branch in registry.branch.values()) - assert await count_active_branches() == 2 + assert await count_active_branches(db=db) == 2 @pytest.fixture @@ -173,7 +173,7 @@ async def _build_gatherer( component=component, account_gatherer=account_gatherer or DefaultAccountGatherer(db=database), activity_gatherer=activity_gatherer or DefaultActivityGatherer(), - active_branch_counter=active_branch_counter or DefaultActiveBranchCounter(), + active_branch_counter=active_branch_counter or DefaultActiveBranchCounter(db=database), ) From 4fd71be4e4b2c2807a51900524ef89ffb4d0e80d Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Wed, 15 Jul 2026 09:52:36 +0200 Subject: [PATCH 43/75] docs(backend): show good/avoid examples in the schema-access guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the ✅/❌ code examples alongside the preference list so the inject / db.schema / registry choice is scannable at a glance. Co-Authored-By: Claude Opus 4.8 --- dev/knowledge/backend/query-pattern.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/dev/knowledge/backend/query-pattern.md b/dev/knowledge/backend/query-pattern.md index b8ae46e189f..0e34cf48e01 100644 --- a/dev/knowledge/backend/query-pattern.md +++ b/dev/knowledge/backend/query-pattern.md @@ -13,8 +13,16 @@ The `registry` is a legitimate in-memory cache (schemas, branches, node classes) 3. **`registry.schema`** — avoid. ```python +# ✅ Best - inject SchemaManager, constructed at the entry point class MyComponent: - def __init__(self, schema_manager: SchemaManager) -> None: ... + def __init__(self, schema_manager: SchemaManager) -> None: + self.schema_manager = schema_manager + +# ✅ OK - db.schema when injection isn't practical (temporally correct) +schema = db.schema.get(name="MyNode", branch=branch) + +# ❌ Avoid - global singleton, loses temporal flexibility +schema = registry.schema.get(name="MyNode") ``` Components already accept `schema_manager`; entry points still pass `registry.schema`, concentrating the access at the boundary on the way to a `get_schema_manager()` accessor and retiring `registry` imports. From dc654d28898d7c013390391a9506bf338ae82d74 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Wed, 15 Jul 2026 10:06:52 +0200 Subject: [PATCH 44/75] fix(telemetry): count active branches via a count query, excluding terminal branches count_active_branches used len(Branch.get_list(...)), which caps at the default limit of 1000 and counts merged (terminal) branches. Switch to Branch.get_list_count with exclude_default/exclude_terminal (exclude_global already applies) so the result is uncapped and reflects only open non-system branches. The two exclusions are added to get_list_count for parity with get_list; the existing GraphQL callers pass neither, so their behavior is unchanged. Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/core/branch/models.py | 4 ++++ backend/infrahub/telemetry/tasks.py | 5 ++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/backend/infrahub/core/branch/models.py b/backend/infrahub/core/branch/models.py index c3b94e21b61..3411489cbb6 100644 --- a/backend/infrahub/core/branch/models.py +++ b/backend/infrahub/core/branch/models.py @@ -207,6 +207,8 @@ async def get_list_count( partial_match: bool = False, branch_filters: BranchListFilters | None = None, node_ordering: StandardNodeOrdering | None = None, + exclude_default: bool = False, + exclude_terminal: bool = False, **_kwargs: Any, ) -> int: if branch_filters is None: @@ -223,6 +225,8 @@ async def get_list_count( branch_filters=branch_filters, limit=limit, exclude_global=True, + exclude_default=exclude_default, + exclude_terminal=exclude_terminal, node_ordering=node_ordering, ) return await query.count(db=db) diff --git a/backend/infrahub/telemetry/tasks.py b/backend/infrahub/telemetry/tasks.py index 3a23d6789f6..3cd301bfb1a 100644 --- a/backend/infrahub/telemetry/tasks.py +++ b/backend/infrahub/telemetry/tasks.py @@ -99,9 +99,8 @@ async def gather_account_information(db: InfrahubDatabase) -> TelemetryAccountDa async def count_active_branches(db: InfrahubDatabase) -> int: - """Count open non-system branches, excluding the default and global branches.""" - branches = await Branch.get_list(db=db, exclude_global=True, exclude_default=True) - return len(branches) + """Count open non-system branches (excludes the default, global, and terminal branches).""" + return await Branch.get_list_count(db=db, exclude_default=True, exclude_terminal=True) class AccountGatherer(Protocol): From 0286ce2fc09d5953b0497d6fd742196b3c682bfb Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Wed, 15 Jul 2026 10:06:53 +0200 Subject: [PATCH 45/75] docs(backend): ground the schema-injection note in existing accessors Name concrete components that already accept schema_manager and reference the existing get_database()/get_component() accessors instead of a not-yet-existing get_schema_manager(), so the guidance can't read as a fabricated reference. Co-Authored-By: Claude Opus 4.8 --- dev/knowledge/backend/query-pattern.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/knowledge/backend/query-pattern.md b/dev/knowledge/backend/query-pattern.md index 0e34cf48e01..f6b8821560a 100644 --- a/dev/knowledge/backend/query-pattern.md +++ b/dev/knowledge/backend/query-pattern.md @@ -25,7 +25,7 @@ schema = db.schema.get(name="MyNode", branch=branch) schema = registry.schema.get(name="MyNode") ``` -Components already accept `schema_manager`; entry points still pass `registry.schema`, concentrating the access at the boundary on the way to a `get_schema_manager()` accessor and retiring `registry` imports. +Components already accept `schema_manager` (the merge orchestrator, diff calculator, schema update coordinator, …); entry points still pass `registry.schema`, concentrating the access at the boundary. The next step is an accessor like the existing `get_database()` / `get_component()` so entry points can drop `registry` entirely. Exception: `registry` stays for hot (per-request) in-memory reads where a DB round-trip is a real regression (e.g. `registry.branch`); cold paths (daily tasks) use the DB. New-code preference — don't sweep existing call sites. From ebeefb32def4e70516948362d0e3f07c3e1e347d Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Wed, 15 Jul 2026 16:53:08 +0200 Subject: [PATCH 46/75] refactor(branch): make exclude_global an explicit get_list_count param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_list_count hardcoded exclude_global=True, so count_active_branches excluded global implicitly while its docstring said it did — the call site couldn't show it. Promote exclude_global to a parameter (default True, so the existing GraphQL callers are unchanged) and pass it explicitly from count_active_branches, so all three exclusions are visible at the call site. Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/core/branch/models.py | 3 ++- backend/infrahub/telemetry/tasks.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/infrahub/core/branch/models.py b/backend/infrahub/core/branch/models.py index 3411489cbb6..0204d530ea8 100644 --- a/backend/infrahub/core/branch/models.py +++ b/backend/infrahub/core/branch/models.py @@ -207,6 +207,7 @@ async def get_list_count( partial_match: bool = False, branch_filters: BranchListFilters | None = None, node_ordering: StandardNodeOrdering | None = None, + exclude_global: bool = True, exclude_default: bool = False, exclude_terminal: bool = False, **_kwargs: Any, @@ -224,7 +225,7 @@ async def get_list_count( node_class=cls, branch_filters=branch_filters, limit=limit, - exclude_global=True, + exclude_global=exclude_global, exclude_default=exclude_default, exclude_terminal=exclude_terminal, node_ordering=node_ordering, diff --git a/backend/infrahub/telemetry/tasks.py b/backend/infrahub/telemetry/tasks.py index 3cd301bfb1a..31b72d21411 100644 --- a/backend/infrahub/telemetry/tasks.py +++ b/backend/infrahub/telemetry/tasks.py @@ -100,7 +100,7 @@ async def gather_account_information(db: InfrahubDatabase) -> TelemetryAccountDa async def count_active_branches(db: InfrahubDatabase) -> int: """Count open non-system branches (excludes the default, global, and terminal branches).""" - return await Branch.get_list_count(db=db, exclude_default=True, exclude_terminal=True) + return await Branch.get_list_count(db=db, exclude_global=True, exclude_default=True, exclude_terminal=True) class AccountGatherer(Protocol): From acdb3eeb65d889827bd58685d3878c5901e229f6 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Wed, 15 Jul 2026 19:03:24 +0200 Subject: [PATCH 47/75] refactor(branch): align get_list_count exclude_global default with get_list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_list_count defaulted exclude_global=True while its sibling get_list defaults False. Align both to False and have the callers that need the global branch excluded pass exclude_global=True explicitly (the GraphQL branch count and telemetry's active-branch count). No behavior change — the GraphQL branch list already passed it explicitly. Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/core/branch/models.py | 2 +- backend/infrahub/graphql/queries/branch.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/infrahub/core/branch/models.py b/backend/infrahub/core/branch/models.py index 0204d530ea8..5f6fb0c7abf 100644 --- a/backend/infrahub/core/branch/models.py +++ b/backend/infrahub/core/branch/models.py @@ -207,7 +207,7 @@ async def get_list_count( partial_match: bool = False, branch_filters: BranchListFilters | None = None, node_ordering: StandardNodeOrdering | None = None, - exclude_global: bool = True, + exclude_global: bool = False, exclude_default: bool = False, exclude_terminal: bool = False, **_kwargs: Any, diff --git a/backend/infrahub/graphql/queries/branch.py b/backend/infrahub/graphql/queries/branch.py index 18a8c504e34..ed968f29e7d 100644 --- a/backend/infrahub/graphql/queries/branch.py +++ b/backend/infrahub/graphql/queries/branch.py @@ -135,6 +135,7 @@ async def infrahub_branch_resolver( graphql_context=info.context, branch_filters=branch_filters, node_ordering=node_ordering, + exclude_global=True, ) if "default_branch" in fields: From 6620367f6bab99c4e81223810f87a1b67cf2720c Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Wed, 15 Jul 2026 19:03:25 +0200 Subject: [PATCH 48/75] refactor(telemetry): tidy review findings - read count-by buckets the same way in gather_prefect_events as count_windowed_event - drop the redundant explicit default branch in count_corenode (NodeManager.count resolves it) - trim docstrings to state what each function does rather than the rationale Co-Authored-By: Claude Opus 4.8 --- backend/infrahub/telemetry/database.py | 21 ++++----------------- backend/infrahub/telemetry/task_manager.py | 13 +++---------- backend/infrahub/telemetry/tasks.py | 13 ++----------- backend/infrahub/telemetry/utils.py | 18 ++++-------------- 4 files changed, 13 insertions(+), 52 deletions(-) diff --git a/backend/infrahub/telemetry/database.py b/backend/infrahub/telemetry/database.py index 4d174a6bcad..c0c0a57b0b0 100644 --- a/backend/infrahub/telemetry/database.py +++ b/backend/infrahub/telemetry/database.py @@ -52,20 +52,12 @@ async def get_system_info(db: InfrahubDatabase) -> TelemetryDatabaseSystemInfoDa async def count_corenode(db: InfrahubDatabase) -> int: - """Count managed nodes visible on the default branch at gather time. - - Uses the branch-safe count path so the total matches what the product reports for the - default branch, rather than a raw vertex/label tally. - """ - return await NodeManager.count(db=db, schema=InfrahubKind.NODE, branch=registry.get_branch_from_registry()) + """Count managed (CoreNode) nodes on the default branch.""" + return await NodeManager.count(db=db, schema=InfrahubKind.NODE) async def count_user_nodes(db: InfrahubDatabase) -> int: - """Count concrete nodes in user-editable namespaces. - - Group-generic kinds are excluded because they do not carry the ``CoreNode`` label, which - would otherwise let ``user`` exceed ``corenode``. - """ + """Count concrete nodes in user-editable namespaces, excluding group-generic kinds.""" default_branch = registry.get_branch_from_registry() schema_branch = db.schema.get_schema_branch(name=default_branch.name) user_namespaces = [namespace.name for namespace in schema_branch.get_namespaces() if namespace.user_editable] @@ -78,12 +70,7 @@ async def count_user_nodes(db: InfrahubDatabase) -> int: @task(name="telemetry-gather-db", task_run_name="Gather Database Information", cache_policy=NONE) async def gather_database_information(db: InfrahubDatabase) -> TelemetryDatabaseData: - """Gather node/relationship counts and database server/system info. - - ``node_count`` holds three strictly nesting metrics, ``user`` ⊆ ``corenode`` ⊆ ``total``; - ``corenode`` and ``user`` degrade to ``None`` independently, leaving ``total`` and the - per-graph-label counts intact. - """ + """Gather node/relationship counts and database server/system info.""" async with db.start_session(read_only=True) as dbs: server_info = [] system_info = None diff --git a/backend/infrahub/telemetry/task_manager.py b/backend/infrahub/telemetry/task_manager.py index efae77d556e..3f2369a6c97 100644 --- a/backend/infrahub/telemetry/task_manager.py +++ b/backend/infrahub/telemetry/task_manager.py @@ -67,7 +67,7 @@ async def gather_prefect_events(client: PrefectClient) -> dict[str, Any]: for event in infrahub_events: payload = {"filter": {"event": {"name": [event.event_name]}}} buckets = await _post_count_by(client=client, path="/events/count-by/event", payload=payload) - events[event.event_name] = buckets[0]["count"] if buckets else 0 + events[event.event_name] = sum(bucket.get("count", 0) for bucket in buckets) return events @@ -96,11 +96,7 @@ async def count_windowed_event( async def count_windowed_unique_resources( client: PrefectClient, event_name: str, window_start: datetime, window_end: datetime ) -> int: - """Count distinct resources emitting one event within ``[window_start, window_end)``. - - Count-by-resource returns one bucket per distinct resource id, so the bucket count is the - distinct total. - """ + """Count distinct resources emitting one event within ``[window_start, window_end)``.""" payload = _windowed_event_filter(event_name=event_name, window_start=window_start, window_end=window_end) buckets = await _post_count_by(client=client, path="/events/count-by/resource", payload=payload) return len(buckets) @@ -136,10 +132,7 @@ def runs_in_states(states: list[StateType]) -> FlowRunFilter: @task(name="telemetry-gather-activity-24h", task_run_name="Gather 24h Activity", cache_policy=NONE) async def gather_activity_24h(client: PrefectClient) -> TelemetryActivity24hData: - """Assemble the 24h activity metrics over the previous full UTC calendar day. - - Each source is isolated so one failing source nulls only its own field. - """ + """Assemble the 24h activity metrics over the previous full UTC calendar day.""" window_start, window_end = get_activity_window() async def windowed_count(event_name: str) -> int | None: diff --git a/backend/infrahub/telemetry/tasks.py b/backend/infrahub/telemetry/tasks.py index 31b72d21411..49165309246 100644 --- a/backend/infrahub/telemetry/tasks.py +++ b/backend/infrahub/telemetry/tasks.py @@ -72,11 +72,7 @@ async def gather_feature_information() -> dict[str, int]: @task(name="telemetry-account-information", task_run_name="Gather Account Information", cache_policy=NONE) async def gather_account_information(db: InfrahubDatabase) -> TelemetryAccountData: - """Gather account adoption counts on the default branch. - - Counted through the branch/temporal-correct path so they match the GraphQL resolvers; each - field degrades to ``null`` independently on failure. - """ + """Gather active-account and account-group counts on the default branch.""" default_branch = registry.get_branch_from_registry() active = await safe_metric( @@ -138,12 +134,7 @@ async def count(self) -> int: class AnonymousTelemetryGatherer: - """Assemble the full telemetry payload from injected, independently-degrading sources. - - The account, activity, and active-branch collaborators are injected so each can be swapped - for a failing double in tests. Every one is isolated, so a single failing source nulls only - its own field(s) while the rest of the payload is still built. - """ + """Assemble the full telemetry payload from its injected metric sources.""" def __init__( self, diff --git a/backend/infrahub/telemetry/utils.py b/backend/infrahub/telemetry/utils.py index 209726170ae..015b0dd6bcc 100644 --- a/backend/infrahub/telemetry/utils.py +++ b/backend/infrahub/telemetry/utils.py @@ -20,10 +20,9 @@ def determine_infrahub_type() -> InfrahubType: async def safe_metric[T](coro: Awaitable[T]) -> T | None: - """Run one metric coroutine in isolation, degrading a failure to ``None``. + """Await ``coro`` and return its result, or ``None`` if it raises (the error is logged). - A falsy result like ``0`` is preserved (measured, nothing to count); only an exception - yields ``None`` (logged), so one broken source nulls its own field, not the whole payload. + A falsy result such as ``0`` is returned as-is; only an exception maps to ``None``. """ try: return await coro @@ -39,11 +38,7 @@ def floor_to_midnight_utc(moment: datetime) -> datetime: def get_activity_window(now: datetime | None = None) -> tuple[datetime, datetime]: - """Return the half-open ``[window_start, window_end)`` for the previous full UTC day. - - Anchored to the midnight-UTC boundary, not the raw ``now`` instant, so consecutive daily - runs tile exactly — no overlaps or gaps — whatever time the (jittered) cron fires. - """ + """Return the half-open ``[window_start, window_end)`` for the previous full UTC day.""" reference = now if now is not None else datetime.now(tz=UTC) window_end = floor_to_midnight_utc(reference) window_start = window_end - WINDOW_LENGTH @@ -51,10 +46,5 @@ def get_activity_window(now: datetime | None = None) -> tuple[datetime, datetime def inclusive_end(window_end: datetime) -> datetime: - """The last instant inside the half-open ``[window_start, window_end)`` window. - - ``window_end`` itself is excluded; at microsecond resolution the last included instant is one - microsecond earlier. Query APIs whose upper bound is inclusive ("at or before") need this so - a record on the boundary lands in exactly one day's window, never two. - """ + """Return the last instant inside the half-open window: ``window_end`` minus one microsecond.""" return window_end - timedelta(microseconds=1) From 467b007060ad076556bbf79620ddd65e19f59c99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Lem=C3=A9nager?= Date: Tue, 28 Jul 2026 16:36:02 +0200 Subject: [PATCH 49/75] fix(frontend): filter relationship selectors by common_parent (#10039) Relationship selectors in object forms now honor the `common_parent` schema property: options are filtered to peers that share the same parent as the value picked for the referenced relationship in the same form, instead of listing every peer. - Add useCommonParentFilter hook: watches the sibling common_parent field and derives a single-hop `__ids` filter (plus a matching RelationshipInput parent shape). - Apply across the many, node, generic, and hierarchical relationship fields. - Hide the manual parent picker on cardinality-one fields when common_parent is set, since the parent is then driven by the sibling field. Keep the selection consistent with the filter so it can't be bypassed: - Clear the current selection when the chosen parent changes (a peer valid under the old parent no longer satisfies the constraint). - Pre-fill the inline "Add new" create form with the chosen parent, so a peer created there satisfies the constraint instead of being an escape hatch. - Keep the filter applied on UUID searches (opt-in), and drop the hierarchical "Explore" tree tab, which browses a different axis and can't honor the filter. Fixes IFC-1646. Co-authored-by: Claude Opus 4.8 (1M context) --- ...common-parent-relationship-filter.fixed.md | 1 + frontend/app/.betterer.results | 18 +-- .../ui/add-relationship-action.tsx | 5 + .../ui/relationship-combobox-list.test.tsx | 30 +++++ .../ui/relationship-combobox-list.tsx | 15 ++- .../ui/relationship-hierarchical-input.tsx | 54 +++++++- .../relationship-many.common-parent.test.tsx | 107 ++++++++++++++++ ...eneric-relationship.common-parent.test.tsx | 115 ++++++++++++++++++ .../generic-relationship.field.tsx | 15 ++- ...egular-relationship.common-parent.test.tsx | 107 ++++++++++++++++ .../regular-relationship.field.tsx | 19 ++- ...onship-hierarchical.common-parent.test.tsx | 83 +++++++++++++ .../relationship-hierarchical.field.tsx | 12 ++ .../relationships/relationship-many.field.tsx | 12 +- .../useCommonParentFilter.test.tsx | 112 +++++++++++++++++ .../relationships/useCommonParentFilter.ts | 69 +++++++++++ .../components/inputs/relationship-many.tsx | 13 +- .../components/inputs/relationship-one.tsx | 4 + 18 files changed, 765 insertions(+), 26 deletions(-) create mode 100644 changelog/+common-parent-relationship-filter.fixed.md create mode 100644 frontend/app/src/shared/components/form/fields/relationship-many.common-parent.test.tsx create mode 100644 frontend/app/src/shared/components/form/fields/relationships/generic-relationship.common-parent.test.tsx create mode 100644 frontend/app/src/shared/components/form/fields/relationships/regular-relationship.common-parent.test.tsx create mode 100644 frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.common-parent.test.tsx create mode 100644 frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.test.tsx create mode 100644 frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.ts diff --git a/changelog/+common-parent-relationship-filter.fixed.md b/changelog/+common-parent-relationship-filter.fixed.md new file mode 100644 index 00000000000..b5cb214e4b0 --- /dev/null +++ b/changelog/+common-parent-relationship-filter.fixed.md @@ -0,0 +1 @@ +Fixed relationship selectors in object forms not honoring the `common_parent` schema property. The options are now filtered to peers that share the same parent as the value picked for the referenced relationship in the same form, instead of listing every peer. Changing that parent clears a now-invalid selection, and the inline "Add new" form pre-fills the parent when one is already selected so a created peer stays valid. diff --git a/frontend/app/.betterer.results b/frontend/app/.betterer.results index ac4f652d51d..0d429602f86 100644 --- a/frontend/app/.betterer.results +++ b/frontend/app/.betterer.results @@ -125,9 +125,9 @@ exports[`fix ts error`] = { "src/entities/nodes/relationships/ui/queries/get-default-parent.query.ts:2275135453": [ [18, 34, 2, "tsc: Property \'id\' does not exist on type \'NodeCore | NodeCore[] | { from_pool: { id: string; }; }\'.\\n Property \'id\' does not exist on type \'NodeCore[]\'.", "5861160"] ], - "src/entities/nodes/relationships/ui/relationship-combobox-list.tsx:1067691755": [ - [69, 59, 10, "tsc: No overload matches this call.\\n Overload 1 of 2, \'(predicate: (value: NodeCore, index: number, array: NodeCore[]) => value is NodeCore, thisArg?: any): NodeCore[]\', gave the following error.\\n Argument of type \'(relationshipNode: RelationshipNode) => boolean\' is not assignable to parameter of type \'(value: NodeCore, index: number, array: NodeCore[]) => value is NodeCore\'.\\n Types of parameters \'relationshipNode\' and \'value\' are incompatible.\\n Type \'NodeCore\' is not assignable to type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.\\n Overload 2 of 2, \'(predicate: (value: NodeCore, index: number, array: NodeCore[]) => unknown, thisArg?: any): NodeCore[]\', gave the following error.\\n Argument of type \'(relationshipNode: RelationshipNode) => boolean\' is not assignable to parameter of type \'(value: NodeCore, index: number, array: NodeCore[]) => unknown\'.\\n Types of parameters \'relationshipNode\' and \'value\' are incompatible.\\n Type \'NodeCore\' is not assignable to type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2021508816"], - [76, 41, 4, "tsc: Argument of type \'NodeCore\' is not assignable to parameter of type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2087865285"] + "src/entities/nodes/relationships/ui/relationship-combobox-list.tsx:2789186293": [ + [76, 59, 10, "tsc: No overload matches this call.\\n Overload 1 of 2, \'(predicate: (value: NodeCore, index: number, array: NodeCore[]) => value is NodeCore, thisArg?: any): NodeCore[]\', gave the following error.\\n Argument of type \'(relationshipNode: RelationshipNode) => boolean\' is not assignable to parameter of type \'(value: NodeCore, index: number, array: NodeCore[]) => value is NodeCore\'.\\n Types of parameters \'relationshipNode\' and \'value\' are incompatible.\\n Type \'NodeCore\' is not assignable to type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.\\n Overload 2 of 2, \'(predicate: (value: NodeCore, index: number, array: NodeCore[]) => unknown, thisArg?: any): NodeCore[]\', gave the following error.\\n Argument of type \'(relationshipNode: RelationshipNode) => boolean\' is not assignable to parameter of type \'(value: NodeCore, index: number, array: NodeCore[]) => unknown\'.\\n Types of parameters \'relationshipNode\' and \'value\' are incompatible.\\n Type \'NodeCore\' is not assignable to type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2021508816"], + [83, 41, 4, "tsc: Argument of type \'NodeCore\' is not assignable to parameter of type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2087865285"] ], "src/entities/nodes/relationships/ui/relationship-hierarchical-combobox-list.tsx:3738094152": [ [156, 61, 10, "tsc: No overload matches this call.\\n Overload 1 of 2, \'(predicate: (value: NodeCore, index: number, array: NodeCore[]) => value is NodeCore, thisArg?: any): NodeCore[]\', gave the following error.\\n Argument of type \'(relationshipNode: RelationshipNode) => boolean\' is not assignable to parameter of type \'(value: NodeCore, index: number, array: NodeCore[]) => value is NodeCore\'.\\n Types of parameters \'relationshipNode\' and \'value\' are incompatible.\\n Type \'NodeCore\' is not assignable to type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.\\n Overload 2 of 2, \'(predicate: (value: NodeCore, index: number, array: NodeCore[]) => unknown, thisArg?: any): NodeCore[]\', gave the following error.\\n Argument of type \'(relationshipNode: RelationshipNode) => boolean\' is not assignable to parameter of type \'(value: NodeCore, index: number, array: NodeCore[]) => unknown\'.\\n Types of parameters \'relationshipNode\' and \'value\' are incompatible.\\n Type \'NodeCore\' is not assignable to type \'RelationshipNode\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2021508816"], @@ -320,8 +320,8 @@ exports[`fix ts error`] = { [74, 9, 21, "tsc: Property \'type\' is missing in type \'\\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; name: string; peer: string; kind: \\"Generic\\" | \\"agnostic\\" | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"cascade\\" | \\"extra\\"; }; filterQuery?: Record | \\"valueAsNumber\\" | boolean | null | null | null | null | null | null | null | null | null | null | null | number | string[]> | undefined; allow_override: \\"none\\" | undefined; cardinality: \\"one\\" | undefined; common_relatives?: string[] | undefined; description?: string | undefined; description?: string | undefined; disabled?: boolean | undefined; display: \\"default\\" | undefined; identifier?: string | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; isBulkUpdate?: boolean | undefined; name: string; label?: string | undefined; onChange?: ((value: FormFieldValue) => void) | undefined; on_delete?: \\"no-action\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; options?: SelectOption[] | undefined; order_weight?: number | undefined; parent?: string | undefined; peer?: string | undefined; peerField?: string | undefined; placeholder?: string | undefined; pool?: { kind: string; defaultAllocatedObjectKind: string; fromPoolRelationshipName?: string | undefined; relationship: { id?: string | undefined; rules?: Omit, \\"disabled\\" | undefined; shouldUnregister?: boolean | undefined; state: \\"present\\" | undefined; } | undefined; } | { unique: true; defaultValue?: FormRelationshipValue\' but required in type \'DynamicRelationshipFieldProps\'.", "2975306552"], [86, 9, 21, "tsc: Property \'type\' is missing in type \'\\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; name: string; peer: string; kind: \\"Generic\\" | \\"agnostic\\" | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"cascade\\" | \\"extra\\"; }; filterQuery?: Record | undefined; allow_override: \\"none\\" | undefined; cardinality: \\"one\\" | undefined; common_relatives?: string[] | undefined; description?: string | undefined; description?: string | undefined; disabled?: boolean | undefined; display: \\"default\\" | undefined; identifier?: string | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; isBulkUpdate?: boolean | undefined; name: string; label?: string | undefined; onChange?: ((value: FormFieldValue) => void) | undefined; on_delete?: \\"no-action\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; options?: SelectOption[] | undefined; order_weight?: number | undefined; parent?: string | undefined; peer?: string | undefined; peerField?: string | undefined; placeholder?: string | undefined; pool?: { kind: string; defaultAllocatedObjectKind: string; fromPoolRelationshipName?: string | undefined; relationship: { id?: string | undefined; shouldUnregister?: boolean | undefined; state: \\"present\\" | undefined; unique?: boolean | undefined; } | undefined; } | { rules: { required: true; }; defaultValue?: FormRelationshipValue\' but required in type \'DynamicRelationshipFieldProps\'.", "2975306552"] ], - "src/shared/components/form/fields/relationships/generic-relationship.field.tsx:3433690233": [ - [222, 56, 4, "tsc: Property \'name\' does not exist on type \'\\"\\" | \\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; name: string; peer: string; kind: \\"Generic\\" | \\"agnostic\\" | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"cascade\\" | \\"extra\\"; } | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; cardinality: \\"one\\" | undefined; common_relatives?: string[] | undefined; description?: string | undefined; display: \\"default\\" | undefined; identifier?: string | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; on_delete?: \\"no-action\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_weight?: number | undefined; state: \\"present\\" | { id?: string\'.\\n Property \'name\' does not exist on type \'\\"\\"\'.", "2087876002"] + "src/shared/components/form/fields/relationships/generic-relationship.field.tsx:39778637": [ + [231, 54, 4, "tsc: Property \'name\' does not exist on type \'\\"\\" | \\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; name: string; peer: string; kind: \\"Generic\\" | \\"agnostic\\" | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"cascade\\" | \\"extra\\"; } | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; cardinality: \\"one\\" | undefined; common_relatives?: string[] | undefined; description?: string | undefined; display: \\"default\\" | undefined; identifier?: string | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; on_delete?: \\"no-action\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_weight?: number | undefined; state: \\"present\\" | { id?: string\'.\\n Property \'name\' does not exist on type \'\\"\\"\'.", "2087876002"] ], "src/shared/components/form/file-with-profile-form.tsx:3973907063": [ [13, 8, 6, "tsc: Type \'\\"Attribute\\" | \\"Attribute\\" | \\"Component\\" | \\"Component\\" | \\"Group\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Parent\\" | \\"Profile\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"absent\\"; end_range: number; start_range: number; number_pool_id?: string | \\"absent\\"; end_range: number; start_range: number; number_pool_id?: string | \\"absent\\"; min_value?: number | \\"absent\\"; min_value?: number | \\"absent\\"; name: string; description?: string | \\"absent\\"; name: string; description?: string | \\"absent\\"; name: string; kind: string; enum?: unknown[] | \\"absent\\"; name: string; kind: string; enum?: unknown[] | \\"absent\\"; name: string; namespace: string; description?: string | \\"absent\\"; name: string; namespace: string; description?: string | \\"absent\\"; name: string; peer: string; kind: \\"Generic\\" | \\"absent\\"; name: string; peer: string; kind: \\"Generic\\" | \\"absent\\"; regex?: string | \\"absent\\"; regex?: string | \\"absent\\"; regex?: string | \\"absent\\"; regex?: string | \\"absent\\"; } | \\"absent\\"; } | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"any\\"; parameters?: { id?: string | \\"any\\"; parameters?: { id?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"cascade\\" | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; attributes?: { id?: string | undefined; attributes?: { id?: string | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { id?: string | undefined; choices?: { id?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; computed_attribute?: { [key: string]: unknown; } | undefined; computed_attribute?: { [key: string]: unknown; } | undefined; default_value?: unknown; inherited: boolean; allow_override: \\"none\\" | undefined; default_value?: unknown; inherited: boolean; allow_override: \\"none\\" | undefined; deprecation?: string | undefined; deprecation?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; documentation?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash: string; } | undefined; hash: string; } | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; icon?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; kind?: string | undefined; kind?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; on_delete?: \\"no-action\\" | undefined; on_delete?: \\"no-action\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string\' is not assignable to type \'\\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; end_range: number; start_range: number; number_pool_id?: string | \\"absent\\"; min_value?: number | \\"absent\\"; name: string; description?: string | \\"absent\\"; name: string; kind: string; enum?: unknown[] | \\"absent\\"; name: string; namespace: string; description?: string | \\"absent\\"; name: string; peer: string; kind: \\"Generic\\" | \\"absent\\"; regex?: string | \\"absent\\"; regex?: string | \\"absent\\"; } | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; parameters?: { id?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; attributes?: { id?: string | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { id?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { [key: string]: unknown; } | undefined; default_value?: unknown; inherited: boolean; allow_override: \\"none\\" | undefined; deprecation?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash: string; } | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; kind?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; on_delete?: \\"no-action\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string\'.\\n Type \'\\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; end_range: number; start_range: number; number_pool_id?: string | \\"absent\\"; min_value?: number | \\"absent\\"; name: string; description?: string | \\"absent\\"; name: string; kind: string; enum?: unknown[] | \\"absent\\"; name: string; namespace: string; description?: string | \\"absent\\"; name: string; peer: string; kind: \\"Generic\\" | \\"absent\\"; regex?: string | \\"absent\\"; regex?: string | \\"absent\\"; } | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; parameters?: { id?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; attributes?: { id?: string | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { id?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { [key: string]: unknown; } | undefined; default_value?: unknown; inherited: boolean; allow_override: \\"none\\" | undefined; deprecation?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; hash: string; } | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; kind?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; on_delete?: \\"no-action\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string\' is missing 2 properties from type \'\\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; end_range: number; start_range: number; number_pool_id?: string | \\"absent\\"; min_value?: number | \\"absent\\"; name: string; description?: string | \\"absent\\"; name: string; kind: string; enum?: unknown[] | \\"absent\\"; name: string; namespace: string; description?: string | \\"absent\\"; name: string; peer: string; kind: \\"Generic\\" | \\"absent\\"; regex?: string | \\"absent\\"; regex?: string | \\"absent\\"; } | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; parameters?: { id?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; attributes?: { id?: string | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { id?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { [key: string]: unknown; } | undefined; default_value?: unknown; inherited: boolean; allow_override: \\"none\\" | undefined; deprecation?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash: string; } | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; kind?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; on_delete?: \\"no-action\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string\'", "2166186324"] @@ -363,10 +363,10 @@ exports[`fix ts error`] = { "src/shared/components/inputs/peer.tsx:3616063315": [ [66, 23, 5, "tsc: Argument of type \'NodeCore\' is not assignable to parameter of type \'Node\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "189936718"] ], - "src/shared/components/inputs/relationship-one.tsx:3286424152": [ - [97, 38, 2, "tsc: Property \'id\' does not exist on type \'Node | PoolValue\'.\\n Property \'id\' does not exist on type \'PoolValue\'.", "5861160"], - [99, 54, 2, "tsc: Property \'id\' does not exist on type \'Node | PoolValue\'.\\n Property \'id\' does not exist on type \'PoolValue\'.", "5861160"], - [146, 23, 5, "tsc: Argument of type \'NodeCore\' is not assignable to parameter of type \'Node | PoolValue | null\'.\\n Type \'NodeCore\' is not assignable to type \'Node\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "189936718"] + "src/shared/components/inputs/relationship-one.tsx:3115716616": [ + [100, 38, 2, "tsc: Property \'id\' does not exist on type \'Node | PoolValue\'.\\n Property \'id\' does not exist on type \'PoolValue\'.", "5861160"], + [102, 54, 2, "tsc: Property \'id\' does not exist on type \'Node | PoolValue\'.\\n Property \'id\' does not exist on type \'PoolValue\'.", "5861160"], + [150, 23, 5, "tsc: Argument of type \'NodeCore\' is not assignable to parameter of type \'Node | PoolValue | null\'.\\n Type \'NodeCore\' is not assignable to type \'Node\'.\\n Types of property \'display_label\' are incompatible.\\n Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "189936718"] ], "src/shared/components/table/table.tsx:2590261269": [ [80, 40, 23, "tsc: Argument of type \'number | string | tRowValue | undefined\' is not assignable to parameter of type \'number | string | tRowValue\'.\\n Type \'undefined\' is not assignable to type \'number | string | tRowValue\'.", "1426455104"], diff --git a/frontend/app/src/entities/nodes/relationships/ui/add-relationship-action.tsx b/frontend/app/src/entities/nodes/relationships/ui/add-relationship-action.tsx index c1cc8ad2fa1..eea0883eb5a 100644 --- a/frontend/app/src/entities/nodes/relationships/ui/add-relationship-action.tsx +++ b/frontend/app/src/entities/nodes/relationships/ui/add-relationship-action.tsx @@ -5,16 +5,20 @@ import { useState } from "react"; import SlideOver, { SlideOverTitle } from "@/shared/components/display/slide-over"; import ObjectForm, { type ObjectFormProps } from "@/shared/components/form/object-form"; +import type { NodeFieldsWithMetadata } from "@/entities/nodes/types"; import { useSchema } from "@/entities/schema/ui/hooks/useSchema"; export interface AddRelationshipActionProps { peer: string; onSuccess?: ObjectFormProps["onSuccess"]; + // Pre-fills the create form (e.g. the common_parent so a created peer satisfies the constraint). + initialObject?: NodeFieldsWithMetadata; } export const AddRelationshipAction: React.FC = ({ peer, onSuccess, + initialObject, }) => { const { schema } = useSchema(peer); const [open, setOpen] = useState(false); @@ -45,6 +49,7 @@ export const AddRelationshipAction: React.FC = ({ > { setOpen(false); if (!onSuccess) return; diff --git a/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.test.tsx b/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.test.tsx index 8b876e306ad..2926202becc 100644 --- a/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.test.tsx +++ b/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.test.tsx @@ -105,4 +105,34 @@ describe("RelationshipComboboxList", () => { const lastCall = useRelationshipsMock.mock.calls.at(-1)?.[0]; expect(lastCall.filterQuery).toEqual({ ids: ["17a4cdef-1234-4abc-8def-0123456789ab"] }); }); + + test("keeps the caller filterQuery on a UUID search when enforceFilterQueryOnIdSearch is set", async () => { + useRelationshipsMock.mockReturnValue(setupReturn()); + useSchemaMock.mockReturnValue({ schema: { label: "Device" } }); + + const component = await render( + + ); + + // Clear previous calls from initial render + useRelationshipsMock.mockClear(); + + const input = component.getByRole("combobox"); + await input.click(); + await input.fill("17a4cdef-1234-4abc-8def-0123456789ab"); + + await new Promise((resolve) => setTimeout(resolve, 350)); + + // The id restriction and the enforced parent filter are both applied. + const lastCall = useRelationshipsMock.mock.calls.at(-1)?.[0]; + expect(lastCall.filterQuery).toEqual({ + device__ids: ["dev-1"], + ids: ["17a4cdef-1234-4abc-8def-0123456789ab"], + }); + }); }); diff --git a/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.tsx b/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.tsx index 5d0ad903c32..4dda3eceb26 100644 --- a/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.tsx +++ b/frontend/app/src/entities/nodes/relationships/ui/relationship-combobox-list.tsx @@ -24,6 +24,9 @@ export interface RelationshipComboboxListProps value?: RelationshipNode | null; filterItem?: (relationshipNode: RelationshipNode) => boolean; filterQuery?: Record; + // Keep filterQuery applied even on a UUID search. Used when the filter is a hard constraint + // (e.g. common_parent) that a UUID lookup must not bypass. + enforceFilterQueryOnIdSearch?: boolean; } export const RelationshipComboboxList = ({ @@ -33,19 +36,23 @@ export const RelationshipComboboxList = ({ onSelect, filterItem, filterQuery, + enforceFilterQueryOnIdSearch, ...props }: RelationshipComboboxListProps) => { const [search, setSearch] = React.useState(""); const { schema } = useSchema(peer); - // When the user types or pastes a UUID, switch the underlying query from a - // label search to an ids filter. UUID is a maximally specific match, so it - // intentionally overrides any caller-provided filterQuery. + // When the user types or pastes a UUID, switch the underlying query from a label search to an + // ids filter. UUID is a maximally specific match, so it overrides a caller-provided filterQuery + // by default — unless enforceFilterQueryOnIdSearch keeps the filter as a hard constraint. const isUuidSearch = search.length > 0 && isUuid(search); + const idSearchFilterQuery = enforceFilterQueryOnIdSearch + ? { ...filterQuery, ids: [search.trim()] } + : { ids: [search.trim()] }; const { isPending, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useRelationships({ peer, search: isUuidSearch ? undefined : search, - filterQuery: isUuidSearch ? { ids: [search.trim()] } : filterQuery, + filterQuery: isUuidSearch ? idSearchFilterQuery : filterQuery, }); if (error) return ; diff --git a/frontend/app/src/entities/nodes/relationships/ui/relationship-hierarchical-input.tsx b/frontend/app/src/entities/nodes/relationships/ui/relationship-hierarchical-input.tsx index fb214d3a130..45fc3305b03 100644 --- a/frontend/app/src/entities/nodes/relationships/ui/relationship-hierarchical-input.tsx +++ b/frontend/app/src/entities/nodes/relationships/ui/relationship-hierarchical-input.tsx @@ -28,12 +28,30 @@ import { type RelationshipComboboxListProps, } from "@/entities/nodes/relationships/ui/relationship-combobox-list"; import { RelationshipHierarchicalComboboxList } from "@/entities/nodes/relationships/ui/relationship-hierarchical-combobox-list"; - -export interface RelationshipHierarchicalContentProps extends RelationshipComboboxListProps {} +import type { NodeFieldsWithMetadata } from "@/entities/nodes/types"; + +export interface RelationshipHierarchicalContentProps extends RelationshipComboboxListProps { + // The tree explorer browses the peer's own hierarchy and cannot honor an external filterQuery, + // so it is dropped when a filter must be enforced (e.g. common_parent). + hideExplore?: boolean; + // Pre-fills the "Add new" create form so a created peer satisfies an enforced filter. + addNewInitialObject?: NodeFieldsWithMetadata; +} export const RelationshipHierarchicalContent = ({ + hideExplore, + addNewInitialObject, ...props }: RelationshipHierarchicalContentProps) => { + if (hideExplore) { + return ( + + + + + ); + } + return ( @@ -44,7 +62,7 @@ export const RelationshipHierarchicalContent = ({ - + @@ -61,6 +79,10 @@ export interface RelationshipHierarchicalInputProps onChange?: (value: RelationshipNode | null) => void; value?: RelationshipNode | null; peer: string; + filterQuery?: Record; + hideExplore?: boolean; + addNewInitialObject?: NodeFieldsWithMetadata; + enforceFilterQueryOnIdSearch?: boolean; } export const RelationshipHierarchicalInput = ({ @@ -68,6 +90,10 @@ export const RelationshipHierarchicalInput = ({ value, onChange, peer, + filterQuery, + hideExplore, + addNewInitialObject, + enforceFilterQueryOnIdSearch, ...props }: RelationshipHierarchicalInputProps) => { const [open, setOpen] = React.useState(false); @@ -83,7 +109,15 @@ export const RelationshipHierarchicalInput = ({ {value ? getNodeLabel(value) : ""} - + ); }; @@ -94,6 +128,10 @@ export interface RelationshipHierarchicalManyInputProps onChange: (value: RelationshipNode[]) => void; value?: RelationshipNode[] | null; peer: string; + filterQuery?: Record; + hideExplore?: boolean; + addNewInitialObject?: NodeFieldsWithMetadata; + enforceFilterQueryOnIdSearch?: boolean; } export const RelationshipHierarchicalManyInput = ({ @@ -102,6 +140,10 @@ export const RelationshipHierarchicalManyInput = ({ onChange, peer, className, + filterQuery, + hideExplore, + addNewInitialObject, + enforceFilterQueryOnIdSearch, ...props }: RelationshipHierarchicalManyInputProps) => { const [open, setOpen] = React.useState(false); @@ -159,6 +201,10 @@ export const RelationshipHierarchicalManyInput = ({ peer={peer} onSelect={handleSelect} filterItem={(node) => !value?.some((v) => v.id === node.id)} + filterQuery={filterQuery} + hideExplore={hideExplore} + addNewInitialObject={addNewInitialObject} + enforceFilterQueryOnIdSearch={enforceFilterQueryOnIdSearch} /> ); diff --git a/frontend/app/src/shared/components/form/fields/relationship-many.common-parent.test.tsx b/frontend/app/src/shared/components/form/fields/relationship-many.common-parent.test.tsx new file mode 100644 index 00000000000..026c4476b36 --- /dev/null +++ b/frontend/app/src/shared/components/form/fields/relationship-many.common-parent.test.tsx @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { TestForm } from "../../../../../tests/components/form.story"; +import { render } from "../../../../../tests/components/render"; +import { generateRelationshipSchema } from "../../../../../tests/fake/schema"; +import RelationshipManyField from "./relationships/relationship-many.field"; + +// Capture the props handed to the input so we can assert the common_parent wiring +// without driving the whole combobox/query stack. +let lastFilterQuery: unknown; +let lastAddNewInitialObject: unknown; +let lastEnforceOnIdSearch: unknown; +vi.mock("@/shared/components/inputs/relationship-many", () => ({ + RelationshipManyInput: (props: { + filterQuery?: unknown; + addNewInitialObject?: unknown; + enforceFilterQueryOnIdSearch?: unknown; + }) => { + lastFilterQuery = props.filterQuery; + lastAddNewInitialObject = props.addNewInitialObject; + lastEnforceOnIdSearch = props.enforceFilterQueryOnIdSearch; + return ; + }, +})); + +describe("RelationshipManyField - common_parent filtering", () => { + afterEach(() => { + lastFilterQuery = undefined; + lastAddNewInitialObject = undefined; + lastEnforceOnIdSearch = undefined; + vi.clearAllMocks(); + }); + + test("filters the peer options by the common_parent chosen in a sibling field", async () => { + // GIVEN a relationship declaring common_parent: device, with the sibling device picked + const relationship = generateRelationshipSchema({ + name: "profile_one", + peer: "TestProfile", + common_parent: "device", + }); + const deviceValue = { + source: { type: "user" as const }, + value: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" }, + }; + + // WHEN the field renders with that sibling value seeded into the form + await render( + + + + ); + + // THEN the input receives a single-hop filter on the chosen parent; the UUID-search override + // is closed, and "Add new" is pre-filled with that parent so a created peer stays valid. + await expect.poll(() => lastFilterQuery).toEqual({ device__ids: ["dev-1"] }); + expect(lastEnforceOnIdSearch).toBe(true); + expect(lastAddNewInitialObject).toEqual({ + device: { node: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" } }, + }); + }); + + test("passes no filter when the sibling common_parent field is empty", async () => { + const relationship = generateRelationshipSchema({ + name: "profile_one", + peer: "TestProfile", + common_parent: "device", + }); + + await render( + + + + ); + + await expect.poll(() => lastFilterQuery).toBeUndefined(); + }); + + test("passes no filter when the schema declares no common_parent", async () => { + const relationship = generateRelationshipSchema({ name: "tags", peer: "TestProfile" }); + + await render( + + + + ); + + await expect.poll(() => lastFilterQuery).toBeUndefined(); + }); +}); diff --git a/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.common-parent.test.tsx b/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.common-parent.test.tsx new file mode 100644 index 00000000000..1e1eeba3813 --- /dev/null +++ b/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.common-parent.test.tsx @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { store } from "@/shared/stores"; + +import { genericSchemasAtom, nodeSchemasAtom } from "@/entities/schema/stores/schema.atom"; + +import { TestForm } from "../../../../../../tests/components/form.story"; +import { render } from "../../../../../../tests/components/render"; +import { + generateGenericSchema, + generateNodeSchema, + generateRelationshipSchema, +} from "../../../../../../tests/fake/schema"; +import { GenericRelationshipField } from "./generic-relationship.field"; + +// Capture the props handed to the peer picker. +let lastParent: unknown; +let lastAddNewInitialObject: unknown; +vi.mock("@/shared/components/inputs/relationship-one", () => ({ + RelationshipInput: (props: { parent?: unknown; addNewInitialObject?: unknown }) => { + lastParent = props.parent; + lastAddNewInitialObject = props.addNewInitialObject; + return ; + }, +})); + +// A generic peer with a single concrete implementation (auto-selected). The concrete kind has a +// Parent relationship named "device", so the manual picker would show by default. +const concretePeer = generateNodeSchema({ + kind: "TestProfileOne", + name: "ProfileOne", + relationships: [ + generateRelationshipSchema({ + name: "device", + peer: "TestDevice", + kind: "Parent", + cardinality: "one", + optional: false, + }), + ], +}); + +const genericPeer = generateGenericSchema({ + kind: "TestGenericProfile", + name: "GenericProfile", + relationships: [], + used_by: ["TestProfileOne"], +}); + +const deviceValue = { + source: { type: "user" as const }, + value: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" }, +}; + +describe("GenericRelationshipField - common_parent", () => { + beforeEach(() => { + store.set(nodeSchemasAtom, [concretePeer]); + store.set(genericSchemasAtom, [genericPeer]); + }); + afterEach(() => { + lastParent = undefined; + lastAddNewInitialObject = undefined; + vi.clearAllMocks(); + }); + + test("hides the manual parent picker and filters by the sibling when common_parent is set", async () => { + const relationship = generateRelationshipSchema({ + name: "profile_one", + peer: "TestGenericProfile", + cardinality: "one", + common_parent: "device", + }); + + await render( + + + + ); + + await expect.poll(() => lastParent).toEqual({ name: "device", value: "dev-1" }); + expect(document.querySelectorAll('[data-testid="rel-input"]')).toHaveLength(1); + expect(lastAddNewInitialObject).toEqual({ + device: { node: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" } }, + }); + }); + + test("shows the manual parent picker when common_parent is not set", async () => { + const relationship = generateRelationshipSchema({ + name: "profile_one", + peer: "TestGenericProfile", + cardinality: "one", + }); + + await render( + + + + ); + + // Manual parent picker present in addition to the peer picker → two inputs. + await expect.poll(() => document.querySelectorAll('[data-testid="rel-input"]').length).toBe(2); + }); +}); diff --git a/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.field.tsx b/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.field.tsx index f4a9aa813d8..6fc933c6f48 100644 --- a/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.field.tsx +++ b/frontend/app/src/shared/components/form/fields/relationships/generic-relationship.field.tsx @@ -24,6 +24,8 @@ import type { Node } from "@/entities/nodes/getObjectItemDisplayValue"; import { useDefaultParent } from "@/entities/nodes/relationships/ui/queries/get-default-parent.query"; import { useSchema } from "@/entities/schema/ui/hooks/useSchema"; +import { useCommonParentFilter } from "./useCommonParentFilter"; + interface GenericOption extends Node { id: string; display_label: string; @@ -57,6 +59,10 @@ export const GenericRelationshipField = ({ ); const parentRelationship = selectedGeneric?.id && getParentRelationship(selectedGeneric.id); + const commonParent = useCommonParentFilter(relationship, name); + // When common_parent drives the filter from a sibling field, the manual "Parent" picker + // is redundant — hide it and source the peer filter from the sibling value instead. + const showManualParent = !commonParent.isActive && !!parentRelationship; const { data: defaultParent } = useDefaultParent({ defaultValue, @@ -156,7 +162,7 @@ export const GenericRelationshipField = ({ setSelectedGeneric={handleKindChange} /> - {parentRelationship && ( + {showManualParent && parentRelationship && ( diff --git a/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.common-parent.test.tsx b/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.common-parent.test.tsx new file mode 100644 index 00000000000..db50b084221 --- /dev/null +++ b/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.common-parent.test.tsx @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { store } from "@/shared/stores"; + +import { nodeSchemasAtom } from "@/entities/schema/stores/schema.atom"; + +import { TestForm } from "../../../../../../tests/components/form.story"; +import { render } from "../../../../../../tests/components/render"; +import { + generateNodeSchema, + generateRelationshipSchema, +} from "../../../../../../tests/fake/schema"; +import { NodeRelationshipField } from "./regular-relationship.field"; + +// Capture the props handed to the peer picker. +let lastParent: unknown; +let lastAddNewInitialObject: unknown; +vi.mock("@/shared/components/inputs/relationship-one", () => ({ + RelationshipInput: (props: { parent?: unknown; addNewInitialObject?: unknown }) => { + lastParent = props.parent; + lastAddNewInitialObject = props.addNewInitialObject; + return ; + }, +})); + +// Peer has a Parent relationship named "device", so the manual picker would show by default. +const peerSchema = generateNodeSchema({ + kind: "TestProfile", + name: "Profile", + relationships: [ + generateRelationshipSchema({ + name: "device", + peer: "TestDevice", + kind: "Parent", + cardinality: "one", + optional: false, + }), + ], +}); + +const deviceValue = { + source: { type: "user" as const }, + value: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" }, +}; + +describe("NodeRelationshipField - common_parent", () => { + beforeEach(() => { + store.set(nodeSchemasAtom, [peerSchema]); + }); + afterEach(() => { + lastParent = undefined; + lastAddNewInitialObject = undefined; + vi.clearAllMocks(); + }); + + test("hides the manual parent picker and filters by the sibling when common_parent is set", async () => { + const relationship = generateRelationshipSchema({ + name: "profile_one", + peer: "TestProfile", + cardinality: "one", + common_parent: "device", + }); + + await render( + + + + ); + + // Only the peer picker renders (manual parent picker hidden), filtered by the sibling, + // with "Add new" pre-filled so a created peer stays valid. + await expect.poll(() => lastParent).toEqual({ name: "device", value: "dev-1" }); + expect(document.querySelectorAll('[data-testid="rel-input"]')).toHaveLength(1); + expect(lastAddNewInitialObject).toEqual({ + device: { node: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" } }, + }); + }); + + test("shows the manual parent picker when common_parent is not set", async () => { + const relationship = generateRelationshipSchema({ + name: "profile_one", + peer: "TestProfile", + cardinality: "one", + }); + + await render( + + + + ); + + // Manual parent picker present in addition to the peer picker → two inputs. + await expect.poll(() => document.querySelectorAll('[data-testid="rel-input"]').length).toBe(2); + }); +}); diff --git a/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.field.tsx b/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.field.tsx index 20365282f98..f735008e411 100644 --- a/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.field.tsx +++ b/frontend/app/src/shared/components/form/fields/relationships/regular-relationship.field.tsx @@ -17,6 +17,8 @@ import { FormField, FormInput, FormMessage } from "@/shared/components/ui/form"; import type { Node } from "@/entities/nodes/getObjectItemDisplayValue"; import { useDefaultParent } from "@/entities/nodes/relationships/ui/queries/get-default-parent.query"; +import { useCommonParentFilter } from "./useCommonParentFilter"; + export interface RegularRelationshipFieldProps extends DynamicRelationshipFieldProps { parentDisabled?: boolean; defaultParent?: Node | null; @@ -39,6 +41,10 @@ export const NodeRelationshipField = ({ ...props }: RegularRelationshipFieldProps) => { const parentRelationship = getParentRelationship(relationship.peer); + const commonParent = useCommonParentFilter(relationship, name); + // When common_parent drives the filter from a sibling field, the manual "Parent" picker + // is redundant — hide it and source the peer filter from the sibling value instead. + const showManualParent = !commonParent.isActive && !!parentRelationship; const { data: defaultParent } = useDefaultParent({ defaultValue, @@ -61,7 +67,7 @@ export const NodeRelationshipField = ({ return (
- {parentRelationship && ( + {showManualParent && ( )} - {parentRelationship && ( + {showManualParent && ( @@ -143,7 +149,12 @@ export const NodeRelationshipField = ({ value={value} onChange={onChange} peer={peer} - parent={{ name: parentRelationship?.name, value: selectedParent?.id }} + parent={ + commonParent.isActive + ? commonParent.parent + : { name: parentRelationship?.name, value: selectedParent?.id } + } + addNewInitialObject={commonParent.addNewInitialObject} /> diff --git a/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.common-parent.test.tsx b/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.common-parent.test.tsx new file mode 100644 index 00000000000..a1ba7c1f030 --- /dev/null +++ b/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.common-parent.test.tsx @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { TestForm } from "../../../../../../tests/components/form.story"; +import { render } from "../../../../../../tests/components/render"; +import { generateRelationshipSchema } from "../../../../../../tests/fake/schema"; +import RelationshipHierarchicalField from "./relationship-hierarchical.field"; + +// Capture the props handed to the hierarchical inputs. +let lastFilterQuery: unknown; +let lastHideExplore: unknown; +vi.mock("@/entities/nodes/relationships/ui/relationship-hierarchical-input", () => ({ + RelationshipHierarchicalInput: (props: { filterQuery?: unknown; hideExplore?: unknown }) => { + lastFilterQuery = props.filterQuery; + lastHideExplore = props.hideExplore; + return ; + }, + RelationshipHierarchicalManyInput: (props: { filterQuery?: unknown; hideExplore?: unknown }) => { + lastFilterQuery = props.filterQuery; + lastHideExplore = props.hideExplore; + return ; + }, +})); + +const deviceValue = { + source: { type: "user" as const }, + value: { id: "dev-1", display_label: "dc1-device", __typename: "TestDevice" }, +}; + +describe("RelationshipHierarchicalField - common_parent", () => { + afterEach(() => { + lastFilterQuery = undefined; + lastHideExplore = undefined; + vi.clearAllMocks(); + }); + + test("filters by the sibling common_parent value", async () => { + const relationship = generateRelationshipSchema({ + name: "children", + peer: "TestNode", + cardinality: "one", + hierarchical: "TestNode", + common_parent: "device", + }); + + await render( + + + + ); + + await expect.poll(() => lastFilterQuery).toEqual({ device__ids: ["dev-1"] }); + // The tree explorer can't honor the filter, so it is dropped when common_parent applies. + expect(lastHideExplore).toBe(true); + }); + + test("passes no filter when the schema declares no common_parent", async () => { + const relationship = generateRelationshipSchema({ + name: "children", + peer: "TestNode", + cardinality: "one", + hierarchical: "TestNode", + }); + + await render( + + + + ); + + await expect.poll(() => lastFilterQuery).toBeUndefined(); + expect(lastHideExplore).toBe(false); + }); +}); diff --git a/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.field.tsx b/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.field.tsx index 9ed782918b1..18707f66e81 100644 --- a/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.field.tsx +++ b/frontend/app/src/shared/components/form/fields/relationships/relationship-hierarchical.field.tsx @@ -18,6 +18,8 @@ import { } from "@/entities/nodes/relationships/ui/relationship-hierarchical-input"; import type { NodeCore } from "@/entities/nodes/types"; +import { useCommonParentFilter } from "./useCommonParentFilter"; + export interface RelationshipHierarchicalFieldProps extends Omit {} @@ -33,6 +35,8 @@ export default function RelationshipHierarchicalField({ shouldUnregister, pool, }: RelationshipHierarchicalFieldProps) { + const commonParent = useCommonParentFilter(relationship, name); + return ( ) : ( )} diff --git a/frontend/app/src/shared/components/form/fields/relationships/relationship-many.field.tsx b/frontend/app/src/shared/components/form/fields/relationships/relationship-many.field.tsx index 90394c6a980..0bc2b9ce5d5 100644 --- a/frontend/app/src/shared/components/form/fields/relationships/relationship-many.field.tsx +++ b/frontend/app/src/shared/components/form/fields/relationships/relationship-many.field.tsx @@ -12,6 +12,8 @@ import { classNames } from "@/shared/utils/common"; import type { NodeCore } from "@/entities/nodes/types"; +import { useCommonParentFilter } from "./useCommonParentFilter"; + export interface RelationshipManyInputProps extends DynamicRelationshipFieldProps {} export default function RelationshipManyField({ @@ -28,6 +30,8 @@ export default function RelationshipManyField({ filterQuery, ...props }: RelationshipManyInputProps) { + const commonParent = useCommonParentFilter(relationship, name); + return ( :last-child:focus]:border-red-500 has-[>:last-child:focus]:ring-red-500/25" )} peer={relationship.peer} - filterQuery={filterQuery} + filterQuery={ + commonParent.filterQuery + ? { ...filterQuery, ...commonParent.filterQuery } + : filterQuery + } + enforceFilterQueryOnIdSearch={commonParent.isActive} + addNewInitialObject={commonParent.addNewInitialObject} value={fieldData.value as NodeCore[] | null} onChange={(newValue) => { field.onChange( diff --git a/frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.test.tsx b/frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.test.tsx new file mode 100644 index 00000000000..a38ca145d59 --- /dev/null +++ b/frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.test.tsx @@ -0,0 +1,112 @@ +import { useFormContext, useWatch } from "react-hook-form"; +import { describe, expect, test } from "vitest"; + +import { TestForm } from "../../../../../../tests/components/form.story"; +import { render } from "../../../../../../tests/components/render"; +import { generateRelationshipSchema } from "../../../../../../tests/fake/schema"; +import { useCommonParentFilter } from "./useCommonParentFilter"; + +const Probe = ({ commonParent }: { commonParent?: string | null }) => { + const relationship = generateRelationshipSchema({ common_parent: commonParent ?? null }); + const result = useCommonParentFilter(relationship, "dependent"); + return
{JSON.stringify(result)}
; +}; + +const parentValue = { + source: { type: "user" as const }, + value: { id: "dev-1", display_label: "atl1-edge", __typename: "InfraDevice" }, +}; + +describe("useCommonParentFilter", () => { + test("is inactive when the relationship declares no common_parent", async () => { + const component = await render( + + + + ); + + await expect + .element(component.getByTestId("result")) + .toHaveTextContent(JSON.stringify({ isActive: false })); + }); + + test("returns no filter while the sibling field is empty", async () => { + const component = await render( + + + + ); + + await expect + .element(component.getByTestId("result")) + .toHaveTextContent(JSON.stringify({ isActive: true, parent: { name: "device" } })); + }); + + test("builds the single-hop filter from the picked sibling parent", async () => { + const component = await render( + + + + ); + + await expect.element(component.getByTestId("result")).toHaveTextContent( + JSON.stringify({ + isActive: true, + filterQuery: { device__ids: ["dev-1"] }, + parent: { name: "device", value: "dev-1" }, + addNewInitialObject: { + device: { node: { id: "dev-1", display_label: "atl1-edge", __typename: "InfraDevice" } }, + }, + }) + ); + }); +}); + +// Harness that reads the dependent field value and lets the test change the parent. +const ClearHarness = () => { + const relationship = generateRelationshipSchema({ name: "profile", common_parent: "device" }); + useCommonParentFilter(relationship, "profile"); + const form = useFormContext(); + const dependent = useWatch({ name: "profile" }); + + return ( +
+
{JSON.stringify(dependent)}
+ +
+ ); +}; + +describe("useCommonParentFilter - clears the selection on parent change", () => { + test("keeps the pre-filled selection on mount but clears it when the parent changes", async () => { + const selected = { + source: { type: "user" as const }, + value: { id: "profile-1", display_label: "p1-alpha-dc1", __typename: "TestProfile" }, + }; + + const component = await render( + + + + ); + + // Preserved on mount. + await expect.element(component.getByTestId("dependent")).toHaveTextContent("profile-1"); + + // Changing the parent clears the now out-of-filter selection. + await component.getByRole("button", { name: "change parent" }).click(); + await expect + .element(component.getByTestId("dependent")) + .toHaveTextContent(JSON.stringify({ source: null, value: null })); + }); +}); diff --git a/frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.ts b/frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.ts new file mode 100644 index 00000000000..0e1dfe4afa9 --- /dev/null +++ b/frontend/app/src/shared/components/form/fields/relationships/useCommonParentFilter.ts @@ -0,0 +1,69 @@ +import { useEffect, useRef } from "react"; +import { useFormContext, useWatch } from "react-hook-form"; + +import { DEFAULT_FORM_FIELD_VALUE } from "@/shared/components/form/constants"; +import type { FormRelationshipValue } from "@/shared/components/form/type"; + +import type { NodeFieldsWithMetadata } from "@/entities/nodes/types"; +import type { RelationshipSchema } from "@/entities/schema/types"; + +// Matches no field, so the useWatch call stays unconditional without subscribing to the whole +// form when the relationship declares no common_parent. +const NO_COMMON_PARENT = "__no_common_parent__"; + +export interface CommonParentFilter { + isActive: boolean; + // Filter for the record-shape consumers (many / hierarchical). + filterQuery?: Record; + // Filter for RelationshipInput's `parent` prop (cardinality one). + parent?: { name: string; value?: string }; + // Seed pre-filling the inline "Add new" form's parent so a created peer stays valid. + addNewInitialObject?: NodeFieldsWithMetadata; +} + +/** + * For a relationship declaring `common_parent: `, filter the peer options to those sharing the + * `` parent picked for the sibling `` field, via a single-hop `__ids` filter. + */ +export const useCommonParentFilter = ( + relationship: RelationshipSchema, + name: string +): CommonParentFilter => { + const commonParent = relationship.common_parent ?? undefined; + const watched = useWatch({ name: commonParent ?? NO_COMMON_PARENT }) as + | FormRelationshipValue + | undefined; + const form = useFormContext(); + + const value = watched?.value; + const parentNode = value && !Array.isArray(value) && "id" in value ? value : undefined; + const chosenParentId = parentNode?.id; + + // A peer picked under one parent no longer satisfies the constraint once the parent changes, so + // clear it — but skip the first observed value so a pre-filled (edit) selection survives mount. + const previousParentId = useRef(chosenParentId); + const isFirstRun = useRef(true); + useEffect(() => { + if (!commonParent) return; + if (isFirstRun.current) { + isFirstRun.current = false; + previousParentId.current = chosenParentId; + return; + } + if (previousParentId.current !== chosenParentId) { + previousParentId.current = chosenParentId; + form.setValue(name, DEFAULT_FORM_FIELD_VALUE, { shouldDirty: true }); + } + }, [chosenParentId, commonParent, name, form]); + + if (!commonParent) return { isActive: false }; + + return { + isActive: true, + filterQuery: chosenParentId ? { [`${commonParent}__ids`]: [chosenParentId] } : undefined, + parent: { name: commonParent, value: chosenParentId }, + addNewInitialObject: parentNode + ? ({ [commonParent]: { node: parentNode } } as NodeFieldsWithMetadata) + : undefined, + }; +}; diff --git a/frontend/app/src/shared/components/inputs/relationship-many.tsx b/frontend/app/src/shared/components/inputs/relationship-many.tsx index 61031129584..ece77d63174 100644 --- a/frontend/app/src/shared/components/inputs/relationship-many.tsx +++ b/frontend/app/src/shared/components/inputs/relationship-many.tsx @@ -12,7 +12,7 @@ import { classNames } from "@/shared/utils/common"; import { getNodeLabel } from "@/entities/nodes/object/utils/get-node-label"; import { AddRelationshipAction } from "@/entities/nodes/relationships/ui/add-relationship-action"; import { RelationshipComboboxList } from "@/entities/nodes/relationships/ui/relationship-combobox-list"; -import type { NodeCore } from "@/entities/nodes/types"; +import type { NodeCore, NodeFieldsWithMetadata } from "@/entities/nodes/types"; export interface RelationshipManyInputProps extends Omit { @@ -21,6 +21,8 @@ export interface RelationshipManyInputProps peer: string; value: Array | null; filterQuery?: Record; + enforceFilterQueryOnIdSearch?: boolean; + addNewInitialObject?: NodeFieldsWithMetadata; ref?: React.Ref; } @@ -30,6 +32,8 @@ export function RelationshipManyInput({ value, onChange, filterQuery, + enforceFilterQueryOnIdSearch, + addNewInitialObject, ref, ...props }: RelationshipManyInputProps) { @@ -89,8 +93,13 @@ export function RelationshipManyInput({ onSelect={handleSelect} filterItem={(node) => !value?.some((v) => v.id === node.id)} filterQuery={filterQuery} + enforceFilterQueryOnIdSearch={enforceFilterQueryOnIdSearch} + /> + - ); diff --git a/frontend/app/src/shared/components/inputs/relationship-one.tsx b/frontend/app/src/shared/components/inputs/relationship-one.tsx index 5c4106eb521..e909669d80f 100644 --- a/frontend/app/src/shared/components/inputs/relationship-one.tsx +++ b/frontend/app/src/shared/components/inputs/relationship-one.tsx @@ -21,6 +21,7 @@ import type { Node } from "@/entities/nodes/getObjectItemDisplayValue"; import { getNodeLabel } from "@/entities/nodes/object/utils/get-node-label"; import { AddRelationshipAction } from "@/entities/nodes/relationships/ui/add-relationship-action"; import { useRelationships } from "@/entities/nodes/relationships/ui/queries/get-relationships.query"; +import type { NodeFieldsWithMetadata } from "@/entities/nodes/types"; export interface RelationshipInputProps extends Omit { className?: string; @@ -29,6 +30,7 @@ export interface RelationshipInputProps extends Omit; parent?: { name?: string; value?: string }; + addNewInitialObject?: NodeFieldsWithMetadata; ref?: React.Ref>; } @@ -39,6 +41,7 @@ export const RelationshipInput = ({ options, peer, parent, + addNewInitialObject, ref, ...props }: RelationshipInputProps) => { @@ -143,6 +146,7 @@ export const RelationshipInput = ({ {!options && ( { onChange(value); setOpen(false); From 0d089bbcd8185a2c0b9420eb4f8eca805ca8be27 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Wed, 29 Jul 2026 10:14:22 +0100 Subject: [PATCH 50/75] fix: skip merged branches during git repository sync (#9938) fix: skip merged branches during git repository sync A git branch mapping to a merged (read-only) Infrahub branch caused collect_pending_imports to issue a CoreRepositoryUpdate, which the graph rejects for merged branches. The unhandled error aborted the entire sync flow on every cron cycle, so no branches or repositories synced at all. Branches in a terminal status are now skipped during collection, letting the remaining branches and repositories sync as normal. Also: - Add a regression test covering the merged-branch case. - Stub sdk.branch.all() in the git conftest, shared by the sync tests and the lock-scope test. The skip introduces a branch.all() call on the sync path, which the dummy SDK requester answers with an empty payload and raises KeyError: 'Branch'. - Add a changelog fragment. Fixes #9931 --- backend/infrahub/git/repository.py | 21 ++++++ backend/tests/component/git/conftest.py | 8 +++ .../component/git/test_git_repository.py | 7 +- .../component/git/test_sync_lock_scope.py | 3 +- .../git/test_sync_merged_branch.py | 66 +++++++++++++++++++ changelog/9931.fixed.md | 1 + 6 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 backend/tests/integration/git/test_sync_merged_branch.py create mode 100644 changelog/9931.fixed.md diff --git a/backend/infrahub/git/repository.py b/backend/infrahub/git/repository.py index 9cf408981f1..2cd0ed974bb 100644 --- a/backend/infrahub/git/repository.py +++ b/backend/infrahub/git/repository.py @@ -14,6 +14,7 @@ from pydantic import Field from infrahub import config +from infrahub.core.branch.enums import TERMINAL_BRANCH_STATUSES from infrahub.core.constants import InfrahubKind, RepositoryInternalStatus, RepositoryOperationalStatus from infrahub.exceptions import ( CommitNotFoundError, @@ -159,6 +160,10 @@ async def collect_pending_imports(self, staging_branch: str | None = None) -> Co # TODO need to handle properly the situation when a branch is not valid. if self.internal_status == RepositoryInternalStatus.ACTIVE.value: + # A branch that has been merged (or is being deleted) is read-only: recording its commit + # would be rejected by the graph and abort the whole sync, so drop those branches here. + new_branches, updated_branches = await self._exclude_read_only_branches(new_branches, updated_branches) + for branch_name in new_branches: is_valid = self.validate_remote_branch(branch_name=branch_name) if not is_valid: @@ -226,6 +231,22 @@ async def collect_pending_imports(self, staging_branch: str | None = None) -> Co ) return CollectedImports(imports=imports, failed_imports=failed_imports) + async def _exclude_read_only_branches( + self, new_branches: list[str], updated_branches: list[str] + ) -> tuple[list[str], list[str]]: + """Drop branches whose Infrahub branch is in a terminal status (merged or being deleted). + + Such branches are read-only, so recording their commit is rejected by the graph. The default + branch is never terminal, so filtering here does not affect the staging-import path. + """ + terminal_status_values = {status.value for status in TERMINAL_BRANCH_STATUSES} + graph_branches = await self.sdk.branch.all() + read_only = {name for name, branch in graph_branches.items() if branch.status.value in terminal_status_values} + return ( + [name for name in new_branches if self._get_mapped_target_branch(branch_name=name) not in read_only], + [name for name in updated_branches if self._get_mapped_target_branch(branch_name=name) not in read_only], + ) + async def _collect_staging_imports( self, staging_branch: str | None, updated_branches: list[str] ) -> list[PendingObjectImport]: diff --git a/backend/tests/component/git/conftest.py b/backend/tests/component/git/conftest.py index f8bd462af8b..2e2c7ad1bb6 100644 --- a/backend/tests/component/git/conftest.py +++ b/backend/tests/component/git/conftest.py @@ -2,6 +2,7 @@ import shutil from pathlib import Path from typing import Any, Generator +from unittest.mock import AsyncMock, patch import anyio import pytest @@ -33,6 +34,13 @@ def client() -> InfrahubClient: return InfrahubClient(config=Config(address="http://mock", insert_tracker=True)) +@pytest.fixture +def mock_branch_all() -> Generator[AsyncMock]: + """Git sync queries all branches to skip merged/read-only ones; stub the SDK call with no such branches.""" + with patch("infrahub_sdk.branch.InfrahubBranchManager.all", new_callable=AsyncMock, return_value={}) as mock: + yield mock + + @pytest.fixture def git_upstream_repo_02(git_upstream_repo_01: dict[str, str | Path]) -> dict[str, str | Path]: """Delete all the branches but the main branch from git_upstream_repo_01""" diff --git a/backend/tests/component/git/test_git_repository.py b/backend/tests/component/git/test_git_repository.py index a590df5087e..9489feebba4 100644 --- a/backend/tests/component/git/test_git_repository.py +++ b/backend/tests/component/git/test_git_repository.py @@ -560,6 +560,7 @@ async def test_sync_new_branch( git_repo_03: InfrahubRepository, httpx_mock: HTTPXMock, mock_add_branch01_query: HTTPXMock, + mock_branch_all: AsyncMock, ) -> None: repo = git_repo_03 @@ -598,7 +599,9 @@ async def test_sync_new_branch( assert len(worktrees) == 4 -async def test_sync_updated_branch(prefect_test_fixture: None, git_repo_04: InfrahubRepository) -> None: +async def test_sync_updated_branch( + prefect_test_fixture: None, git_repo_04: InfrahubRepository, mock_branch_all: AsyncMock +) -> None: repo = git_repo_04 branch = Branch(name="branch01", uuid=uuid4()) @@ -621,7 +624,7 @@ async def test_sync_updated_branch(prefect_test_fixture: None, git_repo_04: Infr async def test_sync_continues_after_branch_pull_failure( - prefect_test_fixture: None, git_repo_07: InfrahubRepository + prefect_test_fixture: None, git_repo_07: InfrahubRepository, mock_branch_all: AsyncMock ) -> None: """A branch whose pull fails must not prevent the synchronization of the remaining branches.""" repo = git_repo_07 diff --git a/backend/tests/component/git/test_sync_lock_scope.py b/backend/tests/component/git/test_sync_lock_scope.py index e4fd1e7f314..9627099f587 100644 --- a/backend/tests/component/git/test_sync_lock_scope.py +++ b/backend/tests/component/git/test_sync_lock_scope.py @@ -1,3 +1,4 @@ +from unittest.mock import AsyncMock from uuid import uuid4 from infrahub.core.branch import Branch @@ -8,7 +9,7 @@ async def test_repository_lock_scopes_import_build_and_apply( - prefect_test_fixture: None, git_repo_04: InfrahubRepository + prefect_test_fixture: None, git_repo_04: InfrahubRepository, mock_branch_all: AsyncMock ) -> None: """The build phase of an import must run outside the lock and the apply phase inside it. diff --git a/backend/tests/integration/git/test_sync_merged_branch.py b/backend/tests/integration/git/test_sync_merged_branch.py new file mode 100644 index 00000000000..de63cdb53ab --- /dev/null +++ b/backend/tests/integration/git/test_sync_merged_branch.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from git.repo import Repo + +from infrahub.core.branch.enums import BranchStatus +from infrahub.core.constants import InfrahubKind +from infrahub.core.initialization import create_branch +from infrahub.core.node import Node +from infrahub.git import InfrahubRepository +from tests.helpers.test_app import TestInfrahubApp + +if TYPE_CHECKING: + from pathlib import Path + + from infrahub_sdk import InfrahubClient + + from infrahub.database import InfrahubDatabase + from tests.helpers.file_repo import FileRepo + +MERGED_BRANCH = "description-field" + + +class TestSyncMergedBranch(TestInfrahubApp): + async def test_sync_skips_merged_branch( + self, + db: InfrahubDatabase, + client: InfrahubClient, + git_repo_car_dealership: FileRepo, + git_repos_dir: Path, + ) -> None: + """A merged (read-only) branch lingering on the remote must be skipped by the sync. + + Recording its commit issues CoreRepositoryUpdate, which is rejected for a merged branch. That + rejection is not isolated per branch, so it aborts the whole sync instead of skipping the one + branch. + """ + obj = await Node.init(schema=InfrahubKind.REPOSITORY, db=db) + await obj.new( + db=db, + name=git_repo_car_dealership.name, + description="test repository", + location=git_repo_car_dealership.path, + ) + await obj.save(db=db) + + repo = await InfrahubRepository.new( + id=obj.id, + name=git_repo_car_dealership.name, + location=git_repo_car_dealership.path, + client=client, + ) + + # The branch has been merged: it is read-only but its branch object persists in the graph. + branch = await create_branch(branch_name=MERGED_BRANCH, db=db) + branch.status = BranchStatus.MERGED + await branch.save(db=db) + + # The corresponding git branch is not deleted on merge, so it lingers on the remote and the + # local clone does not have it, making the sync treat it as a new branch to record. + Repo(git_repo_car_dealership.path).git.branch(MERGED_BRANCH, "main") + + collected = await repo.collect_pending_imports() + + assert MERGED_BRANCH not in [pending.infrahub_branch_name for pending in collected.imports] diff --git a/changelog/9931.fixed.md b/changelog/9931.fixed.md new file mode 100644 index 00000000000..9301e913e57 --- /dev/null +++ b/changelog/9931.fixed.md @@ -0,0 +1 @@ +Fixed git repository synchronization halting when a branch that had been merged still existed on the remote. From c910c1d63c07c61811cd9ddcdfcb4de581e7b324 Mon Sep 17 00:00:00 2001 From: Patrick Ogenstad Date: Wed, 29 Jul 2026 15:44:39 +0200 Subject: [PATCH 51/75] Remove resolved violations that were previously ignored --- pyproject.toml | 60 -------------------------------------------------- 1 file changed, 60 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cb89e29ce1f..dc02f7c055c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1270,7 +1270,6 @@ no-matching-overload = "ignore" not-subscriptable = "ignore" possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" -unresolved-import = "ignore" unresolved-reference = "ignore" unsupported-operator = "ignore" @@ -1298,7 +1297,6 @@ include = ["utilities/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -possibly-missing-attribute = "ignore" possibly-missing-submodule = "ignore" unresolved-import = "ignore" @@ -1315,8 +1313,6 @@ invalid-assignment = "ignore" missing-argument = "ignore" no-matching-overload = "ignore" not-subscriptable = "ignore" -possibly-missing-attribute = "ignore" -too-many-positional-arguments = "ignore" unresolved-attribute = "ignore" unresolved-import = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -1336,16 +1332,6 @@ too-many-positional-arguments = "ignore" unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments -[[tool.ty.overrides]] -include = ["backend/infrahub/core/query/standard_node.py"] - -[tool.ty.overrides.rules] -################################################################################################## -# The ignored rules below should be removed once the code has been updated, they are included # -# like this so that we can reactivate them one by one. # -################################################################################################## -type-assertion-failure = "ignore" - [[tool.ty.overrides]] include = ["backend/infrahub/core/**"] @@ -1686,10 +1672,8 @@ include = ["backend/infrahub/git/**"] ################################################################################################## invalid-argument-type = "ignore" invalid-assignment = "ignore" -invalid-await = "ignore" invalid-return-type = "ignore" no-matching-overload = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -1701,9 +1685,7 @@ include = ["backend/infrahub/database/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -possibly-missing-attribute = "ignore" invalid-argument-type = "ignore" -invalid-assignment = "ignore" invalid-return-type = "ignore" invalid-type-form = "ignore" unresolved-attribute = "ignore" @@ -1717,9 +1699,7 @@ include = ["backend/infrahub/services/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -deprecated = "ignore" invalid-await = "ignore" -unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -1730,7 +1710,6 @@ include = ["backend/infrahub/services/adapters/**/nats.py"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -possibly-missing-attribute = "ignore" possibly-missing-submodule = "ignore" [[tool.ty.overrides]] @@ -1743,9 +1722,6 @@ include = ["backend/infrahub/proposed_change/**"] ################################################################################################## invalid-argument-type = "ignore" invalid-assignment = "ignore" -invalid-await = "ignore" -no-matching-overload = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -1758,9 +1734,6 @@ include = ["backend/infrahub/generators/**"] # like this so that we can reactivate them one by one. # ################################################################################################## invalid-argument-type = "ignore" -invalid-await = "ignore" -no-matching-overload = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -1775,7 +1748,6 @@ include = ["backend/infrahub/git_credential/**"] invalid-argument-type = "ignore" invalid-assignment = "ignore" no-matching-overload = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -1789,7 +1761,6 @@ include = ["backend/infrahub/webhook/**"] ################################################################################################## invalid-argument-type = "ignore" no-matching-overload = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -1801,8 +1772,6 @@ include = ["backend/infrahub/cli/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -invalid-argument-type = "ignore" -no-matching-overload = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -1813,7 +1782,6 @@ include = ["backend/infrahub/telemetry/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -no-matching-overload = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -1824,9 +1792,7 @@ include = ["backend/infrahub/computed_attribute/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -invalid-argument-type = "ignore" no-matching-overload = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -1839,7 +1805,6 @@ include = ["backend/infrahub/workers/**"] # like this so that we can reactivate them one by one. # ################################################################################################## invalid-argument-type = "ignore" -no-matching-overload = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -1850,7 +1815,6 @@ include = ["backend/infrahub/trigger/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -no-matching-overload = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -1861,7 +1825,6 @@ include = ["backend/infrahub/workflows/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -no-matching-overload = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -1872,7 +1835,6 @@ include = ["backend/infrahub/message_bus/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -call-non-callable = "ignore" call-top-callable = "ignore" invalid-await = "ignore" no-matching-overload = "ignore" @@ -1898,7 +1860,6 @@ include = ["backend/infrahub/artifacts/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -no-matching-overload = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -1910,7 +1871,6 @@ include = ["backend/infrahub/tasks/**"] # like this so that we can reactivate them one by one. # ################################################################################################## missing-argument = "ignore" -no-matching-overload = "ignore" unknown-argument = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -1933,9 +1893,7 @@ include = ["backend/infrahub/profiles/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -invalid-argument-type = "ignore" invalid-assignment = "ignore" -no-matching-overload = "ignore" unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -1971,7 +1929,6 @@ include = ["backend/infrahub/actions/**"] # like this so that we can reactivate them one by one. # ################################################################################################## invalid-argument-type = "ignore" -no-matching-overload = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -1982,7 +1939,6 @@ include = ["backend/infrahub/branch/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -no-matching-overload = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -1993,7 +1949,6 @@ include = ["backend/infrahub/schema/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -no-matching-overload = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -2007,10 +1962,6 @@ include = ["backend/infrahub/*.py"] invalid-argument-type = "ignore" invalid-assignment = "ignore" invalid-method-override = "ignore" -invalid-return-type = "ignore" -no-matching-overload = "ignore" -not-subscriptable = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -2093,7 +2044,6 @@ include = ["backend/infrahub/task_manager/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -invalid-argument-type = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments [[tool.ty.overrides]] @@ -2112,7 +2062,6 @@ missing-argument = "ignore" no-matching-overload = "ignore" not-subscriptable = "ignore" not-iterable = "ignore" -possibly-missing-attribute = "ignore" unknown-argument = "ignore" unresolved-attribute = "ignore" unsupported-operator = "ignore" @@ -2141,11 +2090,9 @@ include = ["backend/tests/integration/**"] invalid-argument-type = "ignore" invalid-assignment = "ignore" invalid-await = "ignore" -invalid-method-override = "ignore" invalid-return-type = "ignore" no-matching-overload = "ignore" not-subscriptable = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unsupported-operator = "ignore" unused-type-ignore-comment = "ignore" # Clashes with mypy's type ignore comments @@ -2163,7 +2110,6 @@ invalid-assignment = "ignore" invalid-return-type = "ignore" no-matching-overload = "ignore" not-iterable = "ignore" -possibly-missing-attribute = "ignore" unknown-argument = "ignore" unresolved-attribute = "ignore" unsupported-operator = "ignore" @@ -2180,7 +2126,6 @@ include = ["backend/tests/integration_docker/**"] invalid-argument-type = "ignore" invalid-assignment = "ignore" invalid-return-type = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" [[tool.ty.overrides]] @@ -2193,7 +2138,6 @@ include = ["backend/tests/scale/**"] ################################################################################################## invalid-assignment = "ignore" not-iterable = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unresolved-import = "ignore" unsupported-operator = "ignore" @@ -2206,8 +2150,6 @@ include = ["backend/tests/fixtures/**"] # The ignored rules below should be removed once the code has been updated, they are included # # like this so that we can reactivate them one by one. # ################################################################################################## -no-matching-overload = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" unresolved-import = "ignore" invalid-argument-type = "ignore" @@ -2242,9 +2184,7 @@ include = ["backend/tests/query_benchmark/**"] # like this so that we can reactivate them one by one. # ################################################################################################## invalid-return-type = "ignore" -possibly-missing-attribute = "ignore" unresolved-attribute = "ignore" -unresolved-import = "ignore" [tool.hatch.build.targets.sdist] include = [ From c14d2995778855c194ba76f382597d4e7eac34a5 Mon Sep 17 00:00:00 2001 From: iddocohen Date: Wed, 29 Jul 2026 17:58:16 +0200 Subject: [PATCH 52/75] docs: add local development and evaluation sizing to hardware requirements (#10071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add local development and evaluation sizing to hardware requirements * docs: address review — recommend 6/8 cores for local eval, drop changelog --- .../hardware-requirements.mdx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/docs/deploy-manage/install-configure/hardware-requirements.mdx b/docs/docs/deploy-manage/install-configure/hardware-requirements.mdx index 40370f0f230..e4b4cd0dabe 100644 --- a/docs/docs/deploy-manage/install-configure/hardware-requirements.mdx +++ b/docs/docs/deploy-manage/install-configure/hardware-requirements.mdx @@ -27,6 +27,25 @@ For cloud deployments, use at least the following machine types: | Oracle Cloud Infrastructure | VM.Standard.E6 | | Alibaba Cloud | ecs.g9a.xlarge | +## Local development and evaluation + +To evaluate Infrahub or build a proof-of-value on a single machine, use the community docker-compose deployment. The requirements below apply to that setup, not to production. + +:::warning + +These are starting points, not a measured specification. Real resource use depends on your dataset size and pipeline concurrency. Validate against your own data with a load test — schema load, data import, and a proposed-change merge — before you treat any figure as a hard requirement. + +::: + +| Resource | Minimum (light evaluation) | Recommended | Notes | +|---------------|----------------------------|-------------|----------------------------------------------------------------------------------------------------------| +| RAM | 8 GB | 16 GB | Recommended matches the general requirements above. | +| CPU | 6 cores | 8 cores | A proposed-change or merge pipeline uses several cores concurrently, so provision headroom. | +| Disk | 20 GB | 40 GB | SSD. Container images require 1.3 GB; data volumes grow with your data and graph history. | +| Storage class | SSD | SSD | Neo4j and PostgreSQL are latency-sensitive, so the storage class matters more than a specific IOPS target. | + +As your dataset grows, large merges and imports slow down on Community Edition — the signal that a single machine is no longer enough. For production scale and performance, see [Community vs enterprise](../../overview/community-vs-enterprise.mdx). + ## Task manager database (PostgreSQL) storage The storage figures in the tables above size the Neo4j graph database. The task manager (Prefect) keeps its own [PostgreSQL database](../../overview/architecture.mdx#task-manager), which you size separately. From f56c1885e6db6dfd1fb76927fa03ac6845f3c0a5 Mon Sep 17 00:00:00 2001 From: iddocohen Date: Wed, 29 Jul 2026 17:58:39 +0200 Subject: [PATCH 53/75] docs: document Community single-core Neo4j limitation (#10072) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: document Community single-core Neo4j limitation * docs: address review — clarify Neo4j Community single-core, drop changelog --- docs/docs/overview/community-vs-enterprise.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/docs/overview/community-vs-enterprise.mdx b/docs/docs/overview/community-vs-enterprise.mdx index 762cf7c1a16..cd347b88a73 100644 --- a/docs/docs/overview/community-vs-enterprise.mdx +++ b/docs/docs/overview/community-vs-enterprise.mdx @@ -153,6 +153,7 @@ This area represents one of the most significant differences between the edition #### Community edition characteristics - **Scale capacity**: Suitable for infrastructures up to ~1,000 devices +- **Database concurrency**: Neo4j Community runs on a single CPU core, so large merges and imports are bound by single-core performance regardless of how many cores the host has - **Database optimization**: Standard optimization suitable for moderate workloads - **Caching**: Basic caching and query optimization - **Deployment model**: Single-instance deployment patterns @@ -165,6 +166,7 @@ Community Edition is fully capable of handling production workloads for small to #### Enterprise edition enhancements - **Scale capacity**: Optimized for environments with 10,000+ devices +- **Database concurrency**: Multi-core Neo4j for parallel query and merge execution - **Database performance**: Advanced tuning, indexing strategies, and query optimization - **Caching architecture**: Enhanced multi-layer caching for improved performance - **High availability**: Built-in clustering support for resilience From 609c042ce14832fa55e688cb274fc476312ee699 Mon Sep 17 00:00:00 2001 From: iddocohen Date: Wed, 29 Jul 2026 17:59:00 +0200 Subject: [PATCH 54/75] docs: link hardware requirements from Community install page (#10073) * docs: link hardware requirements from Community install page * docs: drop changelog for minor doc link --- docs/docs/deploy-manage/install-configure/install/community.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/docs/deploy-manage/install-configure/install/community.mdx b/docs/docs/deploy-manage/install-configure/install/community.mdx index 8c01aeacf3d..06005fcbb50 100644 --- a/docs/docs/deploy-manage/install-configure/install/community.mdx +++ b/docs/docs/deploy-manage/install-configure/install/community.mdx @@ -10,6 +10,8 @@ import ReferenceLink from "../../../../src/components/Card"; Infrahub Community is deployed as a container-based architecture and can be installed using several methods. +Before you install, review the [hardware requirements](../hardware-requirements.mdx), including the local development and evaluation sizing. + From c18d56a10333853521a5bd9242e1ac6f7497f175 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Wed, 29 Jul 2026 16:23:43 +0300 Subject: [PATCH 55/75] refactor(telemetry): review polish for gatherer DI and activity tests Apply review feedback from the collection PR: replace the three single-method protocols with one generic GathererInterface[T], drop the redundant .default() classmethods on the account/activity models, and collapse the eight per-field activity_24h test cases into a single test so the flow gathers once instead of eight times. Co-Authored-By: Claude Fable 5 --- backend/infrahub/telemetry/models.py | 14 +------ backend/infrahub/telemetry/tasks.py | 26 +++++-------- .../component/telemetry/test_task_manager.py | 39 +++++-------------- .../tests/component/telemetry/test_tasks.py | 12 +++--- 4 files changed, 26 insertions(+), 65 deletions(-) diff --git a/backend/infrahub/telemetry/models.py b/backend/infrahub/telemetry/models.py index 3cdece9321f..f2e6a1a4a7f 100644 --- a/backend/infrahub/telemetry/models.py +++ b/backend/infrahub/telemetry/models.py @@ -1,5 +1,3 @@ -from typing import Self - from pydantic import BaseModel, Field from .constants import InfrahubType @@ -19,10 +17,6 @@ class TelemetryAccountData(BaseModel): active: int | None = Field(default=None) groups: int | None = Field(default=None) - @classmethod - def default(cls) -> Self: - return cls() - class TelemetryActivity24hData(BaseModel): logins: int | None = Field(default=None) @@ -38,10 +32,6 @@ class TelemetryActivity24hData(BaseModel): webhooks_fired_success: int | None = Field(default=None) webhooks_fired_failure: int | None = Field(default=None) - @classmethod - def default(cls) -> Self: - return cls() - class TelemetrySchemaData(BaseModel): node_count: int @@ -90,8 +80,8 @@ class TelemetryData(BaseModel): platform: str workers: TelemetryWorkerData branches: TelemetryBranchData - accounts: TelemetryAccountData = Field(default_factory=TelemetryAccountData.default) - activity_24h: TelemetryActivity24hData = Field(default_factory=TelemetryActivity24hData.default) + accounts: TelemetryAccountData = Field(default_factory=TelemetryAccountData) + activity_24h: TelemetryActivity24hData = Field(default_factory=TelemetryActivity24hData) features: dict[str, int] schema_info: TelemetrySchemaData database: TelemetryDatabaseData diff --git a/backend/infrahub/telemetry/tasks.py b/backend/infrahub/telemetry/tasks.py index 49165309246..745361d2ac9 100644 --- a/backend/infrahub/telemetry/tasks.py +++ b/backend/infrahub/telemetry/tasks.py @@ -99,16 +99,8 @@ async def count_active_branches(db: InfrahubDatabase) -> int: return await Branch.get_list_count(db=db, exclude_global=True, exclude_default=True, exclude_terminal=True) -class AccountGatherer(Protocol): - async def gather(self) -> TelemetryAccountData: ... - - -class ActivityGatherer(Protocol): - async def gather(self) -> TelemetryActivity24hData: ... - - -class ActiveBranchCounter(Protocol): - async def count(self) -> int: ... +class GathererInterface[T](Protocol): + async def gather(self) -> T: ... class DefaultAccountGatherer: @@ -129,7 +121,7 @@ class DefaultActiveBranchCounter: def __init__(self, db: InfrahubDatabase) -> None: self.db = db - async def count(self) -> int: + async def gather(self) -> int: return await count_active_branches(db=self.db) @@ -141,9 +133,9 @@ def __init__( *, database: InfrahubDatabase, component: InfrahubComponent, - account_gatherer: AccountGatherer, - activity_gatherer: ActivityGatherer, - active_branch_counter: ActiveBranchCounter, + account_gatherer: GathererInterface[TelemetryAccountData], + activity_gatherer: GathererInterface[TelemetryActivity24hData], + active_branch_counter: GathererInterface[int], ) -> None: self.database = database self.component = component @@ -173,10 +165,10 @@ async def gather(self) -> TelemetryData: ), branches=TelemetryBranchData( total=len(registry.branch), - active=await safe_metric(self.active_branch_counter.count()), + active=await safe_metric(self.active_branch_counter.gather()), ), - accounts=accounts if accounts is not None else TelemetryAccountData.default(), - activity_24h=activity_24h if activity_24h is not None else TelemetryActivity24hData.default(), + accounts=accounts if accounts is not None else TelemetryAccountData(), + activity_24h=activity_24h if activity_24h is not None else TelemetryActivity24hData(), features=await gather_feature_information(), schema_info=await gather_schema_information(branch=default_branch), database=await gather_database_information(db=self.database), diff --git a/backend/tests/component/telemetry/test_task_manager.py b/backend/tests/component/telemetry/test_task_manager.py index 6661f1fe560..11e923da845 100644 --- a/backend/tests/component/telemetry/test_task_manager.py +++ b/backend/tests/component/telemetry/test_task_manager.py @@ -1,7 +1,6 @@ import asyncio import uuid from collections.abc import AsyncGenerator, Generator -from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, cast @@ -266,39 +265,21 @@ async def test_gather_activity_24h_logins(prefect_client: PrefectClient, seeded_ assert data.webhooks_fired_failure is not None -@dataclass -class ActivityFieldCase: - name: str - field: str - event_name: str - - -@pytest.mark.parametrize( - "case", - [ - ActivityFieldCase(name="checks_started", field="checks_started", event_name=ValidatorStartedEvent.event_name), - ActivityFieldCase(name="checks_passed", field="checks_passed", event_name=ValidatorPassedEvent.event_name), - ActivityFieldCase(name="checks_failed", field="checks_failed", event_name=ValidatorFailedEvent.event_name), - ActivityFieldCase( - name="artifacts_created", field="artifacts_created", event_name=ArtifactCreatedEvent.event_name - ), - ActivityFieldCase( - name="artifacts_updated", field="artifacts_updated", event_name=ArtifactUpdatedEvent.event_name - ), - ActivityFieldCase(name="branches_created", field="branches_created", event_name=BranchCreatedEvent.event_name), - ActivityFieldCase(name="branches_merged", field="branches_merged", event_name=BranchMergedEvent.event_name), - ActivityFieldCase(name="branches_deleted", field="branches_deleted", event_name=BranchDeletedEvent.event_name), - ], - ids=lambda case: case.name, -) async def test_gather_activity_24h_checks_artifacts_branches( - prefect_client: PrefectClient, seeded_activity: dict[str, int], case: ActivityFieldCase + prefect_client: PrefectClient, seeded_activity: dict[str, int] ) -> None: # Each field equals exactly the in-window seeded count for its mapped event; the single # out-of-window event per name is excluded, so the count never inflates past the in-window - # total. + # total. One gather covers every field: the flow computes them all in a single pass. data = await gather_activity_24h.fn(client=prefect_client) - assert getattr(data, case.field) == seeded_activity[case.event_name] + assert data.checks_started == seeded_activity[ValidatorStartedEvent.event_name] + assert data.checks_passed == seeded_activity[ValidatorPassedEvent.event_name] + assert data.checks_failed == seeded_activity[ValidatorFailedEvent.event_name] + assert data.artifacts_created == seeded_activity[ArtifactCreatedEvent.event_name] + assert data.artifacts_updated == seeded_activity[ArtifactUpdatedEvent.event_name] + assert data.branches_created == seeded_activity[BranchCreatedEvent.event_name] + assert data.branches_merged == seeded_activity[BranchMergedEvent.event_name] + assert data.branches_deleted == seeded_activity[BranchDeletedEvent.event_name] async def test_gather_prefect_events_unchanged(prefect_client: PrefectClient, seeded_logins: str) -> None: diff --git a/backend/tests/component/telemetry/test_tasks.py b/backend/tests/component/telemetry/test_tasks.py index 4a2158ee9e3..6d076ad89a7 100644 --- a/backend/tests/component/telemetry/test_tasks.py +++ b/backend/tests/component/telemetry/test_tasks.py @@ -28,13 +28,11 @@ count_windowed_unique_resources, ) from infrahub.telemetry.tasks import ( - AccountGatherer, - ActiveBranchCounter, - ActivityGatherer, AnonymousTelemetryGatherer, DefaultAccountGatherer, DefaultActiveBranchCounter, DefaultActivityGatherer, + GathererInterface, build_anonymous_telemetry_gatherer, count_active_branches, gather_account_information, @@ -161,9 +159,9 @@ async def telemetry_environment( async def _build_gatherer( - account_gatherer: AccountGatherer | None = None, - activity_gatherer: ActivityGatherer | None = None, - active_branch_counter: ActiveBranchCounter | None = None, + account_gatherer: GathererInterface[TelemetryAccountData] | None = None, + activity_gatherer: GathererInterface[TelemetryActivity24hData] | None = None, + active_branch_counter: GathererInterface[int] | None = None, ) -> AnonymousTelemetryGatherer: """Build the gatherer with real collaborators, overriding any one with an injected double.""" database = await get_database() @@ -262,7 +260,7 @@ async def gather(self) -> TelemetryActivity24hData: class BoomActiveBranchCounter: - async def count(self) -> int: + async def gather(self) -> int: raise RuntimeError("branch source unavailable") From 9b2fed57f2c1c4d55a62389a5870c0c6d96fbb7c Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Wed, 29 Jul 2026 21:20:18 +0300 Subject: [PATCH 56/75] perf(telemetry): count user nodes in a single branch-aware query count_user_nodes issued one branch-aware count query per concrete kind in user-editable namespaces, which grows to hundreds of sequential round trips on a large schema. Replace the fan-out with CountNodesByKindsQuery: one pass over the graph that resolves each node's winning IS_PART_OF edge (branch hierarchy + timestamp) and counts active nodes grouped by kind. Component tests pin the branch semantics (deleted and branch-only nodes excluded) and assert equivalence with the per-kind NodeManager.count oracle. Co-Authored-By: Claude Fable 5 --- backend/infrahub/telemetry/database.py | 16 +++-- backend/infrahub/telemetry/queries.py | 57 +++++++++++++++ .../component/telemetry/test_database.py | 70 ++++++++++++++++++- 3 files changed, 137 insertions(+), 6 deletions(-) diff --git a/backend/infrahub/telemetry/database.py b/backend/infrahub/telemetry/database.py index c0c0a57b0b0..404ba579278 100644 --- a/backend/infrahub/telemetry/database.py +++ b/backend/infrahub/telemetry/database.py @@ -11,6 +11,7 @@ from infrahub.database import DatabaseType, InfrahubDatabase from .models import TelemetryDatabaseData, TelemetryDatabaseServerData, TelemetryDatabaseSystemInfoData +from .queries import CountNodesByKindsQuery from .utils import safe_metric @@ -61,11 +62,16 @@ async def count_user_nodes(db: InfrahubDatabase) -> int: default_branch = registry.get_branch_from_registry() schema_branch = db.schema.get_schema_branch(name=default_branch.name) user_namespaces = [namespace.name for namespace in schema_branch.get_namespaces() if namespace.user_editable] - total = 0 - for node_schema in schema_branch.get_schemas_for_namespaces(namespaces=user_namespaces): - if isinstance(node_schema, NodeSchema) and InfrahubKind.GENERICGROUP not in node_schema.inherit_from: - total += await NodeManager.count(db=db, schema=node_schema.kind, branch=default_branch) - return total + kinds = [ + node_schema.kind + for node_schema in schema_branch.get_schemas_for_namespaces(namespaces=user_namespaces) + if isinstance(node_schema, NodeSchema) and InfrahubKind.GENERICGROUP not in node_schema.inherit_from + ] + if not kinds: + return 0 + query = await CountNodesByKindsQuery.init(db=db, branch=default_branch, kinds=kinds) + await query.execute(db=db) + return sum(item.count for item in query.get_data()) @task(name="telemetry-gather-db", task_run_name="Gather Database Information", cache_policy=NONE) diff --git a/backend/infrahub/telemetry/queries.py b/backend/infrahub/telemetry/queries.py index b547b4dc31d..ff49703a64d 100644 --- a/backend/infrahub/telemetry/queries.py +++ b/backend/infrahub/telemetry/queries.py @@ -1,10 +1,14 @@ from __future__ import annotations +from dataclasses import dataclass from typing import TYPE_CHECKING, Any +from infrahub.core.query import Query, QueryType from infrahub.core.query.standard_node import StandardNodeGetListQuery if TYPE_CHECKING: + from collections.abc import Generator + from infrahub.database import InfrahubDatabase @@ -28,6 +32,59 @@ def _normalize_end_date(value: str) -> str: return value +@dataclass(frozen=True) +class NodeKindCount: + kind: str + count: int + + +class CountNodesByKindsQuery(Query): + """Count active nodes of the given kinds on the query's branch at the query's time. + + One pass over the graph replaces a per-kind count query fan-out; kinds with no + active node return no row. + """ + + name = "count-nodes-by-kinds" + type = QueryType.READ + insert_return = False + + def __init__(self, kinds: list[str], **kwargs: Any) -> None: + self.kinds = kinds + super().__init__(**kwargs) + + async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: # noqa: ARG002 + branch_filter, branch_params = self.branch.get_query_filter_path(at=self.at) + self.params.update(branch_params) + self.params["kinds"] = self.kinds + + query = """ + MATCH (n:Node) + WHERE n.kind IN $kinds + CALL (n) { + MATCH (n)-[r:IS_PART_OF]->(:Root) + WHERE %(branch_filter)s + RETURN r + // r.status is a tie-breaker for nodes added/deleted at the same time + ORDER BY r.branch_level DESC, r.from DESC, r.status ASC + LIMIT 1 + } + WITH n, r + WHERE r.status = "active" + RETURN n.kind AS kind, count(n) AS total + ORDER BY kind + """ % {"branch_filter": branch_filter} + self.add_to_query(query) + self.update_return_labels(["kind", "total"]) + + def get_data(self) -> Generator[NodeKindCount, None, None]: + for result in self.get_results(): + yield NodeKindCount( + kind=result.get_as_type("kind", str), + count=result.get_as_type("total", int), + ) + + class TelemetrySnapshotGetListQuery(StandardNodeGetListQuery): name = "telemetry-snapshot-get-list" diff --git a/backend/tests/component/telemetry/test_database.py b/backend/tests/component/telemetry/test_database.py index f0292b378ca..a07367e0e09 100644 --- a/backend/tests/component/telemetry/test_database.py +++ b/backend/tests/component/telemetry/test_database.py @@ -1,10 +1,18 @@ from infrahub.core import utils from infrahub.core.branch import Branch from infrahub.core.constants import InfrahubKind +from infrahub.core.initialization import create_branch +from infrahub.core.manager import NodeManager from infrahub.core.node import Node +from infrahub.core.schema import NodeSchema from infrahub.core.schema.schema_branch import SchemaBranch from infrahub.database import InfrahubDatabase -from infrahub.telemetry.database import gather_database_information, get_server_info, get_system_info +from infrahub.telemetry.database import ( + count_user_nodes, + gather_database_information, + get_server_info, + get_system_info, +) async def test_get_server_info(db: InfrahubDatabase) -> None: @@ -98,3 +106,63 @@ async def test_gather_database_information_user_counts_only_user_namespaces( assert user_count <= corenode_count <= total_count # The Core account is a managed node excluded from user, so user is strictly below corenode. assert user_count < corenode_count + + +async def test_count_user_nodes_is_branch_aware( + db: InfrahubDatabase, + default_branch: Branch, + register_core_models_schema: SchemaBranch, + car_person_schema: SchemaBranch, +) -> None: + """``user`` reflects the default branch at gather time: deleted and branch-only nodes are excluded.""" + assert await count_user_nodes(db=db) == 0 + + persons: list[Node] = [] + for index in range(3): + person = await Node.init(db=db, schema="TestPerson", branch=default_branch) + await person.new(db=db, name=f"branch-aware-person-{index}") + await person.save(db=db) + persons.append(person) + + # A node deleted on the default branch stops being counted. + await persons[0].delete(db=db) + + # A node that exists only on another branch is invisible to the default-branch count. + other_branch = await create_branch(branch_name="user-count-branch", db=db) + branch_person = await Node.init(db=db, schema="TestPerson", branch=other_branch) + await branch_person.new(db=db, name="branch-only-person") + await branch_person.save(db=db) + + assert await count_user_nodes(db=db) == 2 + + +async def test_count_user_nodes_matches_per_kind_oracle( + db: InfrahubDatabase, + default_branch: Branch, + register_core_models_schema: SchemaBranch, + car_person_schema: SchemaBranch, +) -> None: + """The single-pass count equals summing a per-kind branch-aware count over the same kinds.""" + seeded_persons = 3 + persons: list[Node] = [] + for index in range(seeded_persons): + person = await Node.init(db=db, schema="TestPerson", branch=default_branch) + await person.new(db=db, name=f"oracle-person-{index}", height=180) + await person.save(db=db) + persons.append(person) + + seeded_cars = 2 + for index in range(seeded_cars): + car = await Node.init(db=db, schema="TestCar", branch=default_branch) + await car.new(db=db, name=f"oracle-car-{index}", nbr_seats=4, is_electric=False, owner=persons[0]) + await car.save(db=db) + + schema_branch = db.schema.get_schema_branch(name=default_branch.name) + user_namespaces = [namespace.name for namespace in schema_branch.get_namespaces() if namespace.user_editable] + oracle = 0 + for node_schema in schema_branch.get_schemas_for_namespaces(namespaces=user_namespaces): + if isinstance(node_schema, NodeSchema) and InfrahubKind.GENERICGROUP not in node_schema.inherit_from: + oracle += await NodeManager.count(db=db, schema=node_schema.kind, branch=default_branch) + + assert oracle == seeded_persons + seeded_cars + assert await count_user_nodes(db=db) == oracle From e0341de9adb86f84904badcba4b38618f20909bb Mon Sep 17 00:00:00 2001 From: Bilal ABBAD Date: Thu, 30 Jul 2026 10:02:49 +0200 Subject: [PATCH 57/75] refactor(frontend): replace Apollo Client with urql for the GraphQL transport [INFP-563] (#10059) --- .../+migrate-apollo-to-urql.housekeeping.md | 1 + dev/guidelines/frontend/route-architecture.md | 4 +- dev/knowledge/frontend/auth-methods.md | 4 +- dev/knowledge/frontend/date-rendering.md | 4 +- dev/knowledge/frontend/entities-structure.md | 11 +- dev/knowledge/frontend/request-priority.md | 22 +- frontend/app/package.json | 5 +- frontend/app/src/app/app.tsx | 18 +- .../domain/use-cases/refresh-access-token.ts | 6 - .../branches/api/create-branch-from-api.ts | 4 +- .../branches/api/delete-branch-from-api.ts | 4 +- .../api/get-branch-action-state-from-api.ts | 5 +- .../api/get-branch-details-from-api.ts | 4 +- .../api/get-branches-count-from-api.ts | 4 +- .../branches/api/get-branches-from-api.ts | 4 +- .../branches/api/merge-branch-from-api.ts | 4 +- .../branches/api/rebase-branch-from-api.ts | 4 +- .../branches/api/validate-branch-from-api.ts | 4 +- .../api/get-artifact-content-diff-from-api.ts | 5 +- .../diff/api/get-check-details-from-api.ts | 4 +- .../diff/api/get-diff-comments-from-api.ts | 5 +- .../diff/api/get-diff-thread-from-api.ts | 5 +- .../diff/api/get-diff-tree-from-api.ts | 4 +- .../api/get-diff-tree-summary-from-api.ts | 4 +- .../api/get-file-content-diff-from-api.ts | 5 +- .../api/get-validator-details-from-api.ts | 4 +- .../diff/api/get-validators-from-api.ts | 4 +- .../diff/api/resolve-conflict-from-api.ts | 4 +- .../entities/diff/api/run-check-from-api.ts | 4 +- .../entities/diff/api/update-diff-from-api.ts | 4 +- .../events/api/get-events-from-api.ts | 4 +- .../generators/api/run-generator-from-api.ts | 4 +- .../groups/api/get-groups-from-api.ts | 5 +- .../api/get-ip-address-list-from-api.ts | 7 +- .../get-next-ip-address-available-from-api.ts | 4 +- .../api/get-ip-namespace-list-from-api.ts | 5 +- .../api/get-ip-prefix-list-from-api.ts | 5 +- .../get-next-ip-prefix-available-from-api.ts | 4 +- .../get-ipam-tree-nodes-by-parent-from-api.ts | 4 +- .../navigation/api/search-from-api.ts | 4 +- .../convert/api/convert-object-from-api.ts | 4 +- ...-object-convert-fields-mapping-from-api.ts | 4 +- .../api/get-object-ancestors-from-api.ts | 5 +- .../api/get-tree-nodes-by-parent-from-api.ts | 5 +- .../object/api/create-object-from-api.ts | 5 +- .../object/api/delete-object-from-api.ts | 5 +- .../object/api/delete-objects-from-api.ts | 5 +- .../object/api/get-display-label-from-api.ts | 5 +- .../object/api/get-node-metadata-from-api.ts | 5 +- .../api/get-object-for-editing-from-api.ts | 6 +- .../nodes/object/api/get-object-from-api.ts | 5 +- .../object/api/get-objects-count-from-api.ts | 5 +- .../nodes/object/api/get-objects-from-api.ts | 5 +- .../object/api/update-object-from-api.ts | 5 +- .../profiles/api/get-profiles-from-api.ts | 5 +- .../api/add-relationships-from-api.ts | 4 +- .../api/get-default-parent-from-api.ts | 5 +- .../api/get-object-relationships-from-api.ts | 5 +- .../api/get-relationship-count-from-api.ts | 5 +- .../get-relationship-properties-from-api.ts | 5 +- .../api/get-relationships-from-api.ts | 7 +- .../api/remove-relationships-from-api.ts | 4 +- .../api/get-path-traversal-from-api.ts | 5 +- .../api/get-reachable-nodes-from-api.ts | 5 +- .../domain/model/path-traversal.ts | 2 +- .../api/get-global-permissions-from-api.ts | 4 +- .../api/get-permissions-from-api.ts | 5 +- .../api/get-effective-preferences-from-api.ts | 4 +- .../api/get-global-preferences-from-api.ts | 4 +- .../api/update-global-preference-from-api.ts | 4 +- .../api/upsert-user-preferences-from-api.ts | 4 +- .../api/create-proposed-change-from-api.ts | 4 +- .../get-proposed-change-details-from-api.ts | 4 +- .../get-proposed-change-thread-from-api.ts | 4 +- ...osed-changes-available-actions-from-api.ts | 9 +- .../get-proposed-changes-counts-from-api.ts | 5 +- .../api/get-proposed-changes-from-api.ts | 5 +- .../update-proposed-change-review-from-api.ts | 4 +- .../api/check-connectivity-from-api.ts | 4 +- .../api/get-repository-group-from-api.ts | 4 +- .../api/import-current-commit-from-api.ts | 4 +- .../api/reimport-last-commit-from-api.ts | 4 +- .../api/allocate-resource-from-api.ts | 5 +- .../api/get-number-pools-from-api.ts | 4 +- .../api/get-pool-utilization-from-api.ts | 4 +- .../api/get-resource-allocated-from-api.ts | 4 +- .../schema/api/add-dropdown-from-api.ts | 4 +- .../entities/schema/api/add-enum-from-api.ts | 4 +- .../schema/api/remove-dropdown-from-api.ts | 4 +- .../schema/api/remove-enum-from-api.ts | 4 +- .../tasks/api/cancel-task-from-api.ts | 4 +- .../tasks/api/check-task-details-from-api.ts | 4 +- .../api/get-branch-task-status-from-api.ts | 4 +- .../tasks/api/get-task-count-from-api.ts | 4 +- .../tasks/api/get-task-details-from-api.ts | 4 +- .../api/get-task-details-title-from-api.ts | 4 +- .../tasks/api/get-task-list-from-api.ts | 4 +- .../tasks/api/get-tasks-homepage-from-api.ts | 4 +- .../entities/tasks/api/retry-task-from-api.ts | 4 +- .../api/create-account-token-from-api.ts | 4 +- .../api/get-account-profile-from-api.ts | 4 +- .../api/get-account-token-from-api.ts | 4 +- .../api/update-account-password-from-api.ts | 4 +- .../shared/api/graphql/client.dedup.test.ts | 77 ++++ .../app/src/shared/api/graphql/client.test.ts | 379 ++++++++++++++++++ frontend/app/src/shared/api/graphql/client.ts | 134 +++++++ .../src/shared/api/graphql/error-handling.ts | 75 ++++ .../api/graphql/graphqlClientApollo.test.ts | 260 ------------ .../api/graphql/graphqlClientApollo.tsx | 257 ------------ frontend/app/src/shared/api/graphql/types.ts | 13 + frontend/app/src/shared/api/rest/client.ts | 7 - .../components/inputs/dropdown.test.tsx | 9 +- .../shared/components/inputs/enum.test.tsx | 9 +- frontend/pnpm-lock.yaml | 213 ++-------- 114 files changed, 874 insertions(+), 1051 deletions(-) create mode 100644 changelog/+migrate-apollo-to-urql.housekeeping.md create mode 100644 frontend/app/src/shared/api/graphql/client.dedup.test.ts create mode 100644 frontend/app/src/shared/api/graphql/client.test.ts create mode 100644 frontend/app/src/shared/api/graphql/client.ts create mode 100644 frontend/app/src/shared/api/graphql/error-handling.ts delete mode 100644 frontend/app/src/shared/api/graphql/graphqlClientApollo.test.ts delete mode 100644 frontend/app/src/shared/api/graphql/graphqlClientApollo.tsx create mode 100644 frontend/app/src/shared/api/graphql/types.ts diff --git a/changelog/+migrate-apollo-to-urql.housekeeping.md b/changelog/+migrate-apollo-to-urql.housekeeping.md new file mode 100644 index 00000000000..be3ff299a9f --- /dev/null +++ b/changelog/+migrate-apollo-to-urql.housekeeping.md @@ -0,0 +1 @@ +Replaced the frontend GraphQL transport (`@apollo/client`) with the lighter `@urql/core`, reducing the JavaScript bundle size. Apollo was used transport-only (no hooks, no cache); all request behavior — auth, request priority, error routing, token refresh, and file uploads — is preserved. No user-facing behavior changes. diff --git a/dev/guidelines/frontend/route-architecture.md b/dev/guidelines/frontend/route-architecture.md index 7a8b2390fee..7a578a2b380 100644 --- a/dev/guidelines/frontend/route-architecture.md +++ b/dev/guidelines/frontend/route-architecture.md @@ -310,8 +310,8 @@ export function DatePreferencesProvider({ children }) { Why this matters, and why **`RequireAuth` is not the gate**: `RequireAuth` renders its children when `isAuthenticated || config.main.allow_anonymous_access`, so with anonymous access enabled the whole authenticated route tree — and any provider mounted in it — renders for **logged-out** users. An -ungated authenticated query then 401s, Apollo's `errorLink` calls `redirectToLogin`, and the user is -bounced to `/login`. Because it depends on timing/anonymous-access it surfaces as a **flaky E2E +ungated authenticated query then 401s, the transport's error routing calls `redirectToLogin`, and the +user is bounced to `/login`. Because it depends on timing/anonymous-access it surfaces as a **flaky E2E failure** (auth-setup timeout, or "not logged in" specs), not an obvious error. Prefer the mount gate above over react-query `enabled: isAuthenticated` — it keeps the query hook diff --git a/dev/knowledge/frontend/auth-methods.md b/dev/knowledge/frontend/auth-methods.md index c9559eb2d09..6086f662d37 100644 --- a/dev/knowledge/frontend/auth-methods.md +++ b/dev/knowledge/frontend/auth-methods.md @@ -43,7 +43,9 @@ authentication/ Import direction is the entity rule: `ui/ → domain/ → api/`. SSO has no api/domain/queries (it's a redirect link list, not a fetch). Storage keys live with their consumers: token keys in `api/token-storage.ts`, `LAST_USED_METHOD_KEY` in `ui/hooks/use-last-used-method.ts`. -> `shared/` transport (`api/rest/client`, `api/graphql/graphqlClientApollo`, graphiql fetcher) imports this entity's token surface (`api/token-storage`, `domain/use-cases/redirect-to-login`) — the one sanctioned `shared → entity` transport edge (auth is cross-cutting). `api/rest/client` and `api/graphql/graphqlClientApollo` also import `ui/queries/refresh-access-token.query`, sharing one TanStack query so concurrent 401s trigger a single refresh. +> `shared/` transport (`api/rest/client`, `api/graphql/client`, graphiql fetcher) imports this entity's token surface (`api/token-storage`, `domain/use-cases/redirect-to-login`) — the one sanctioned `shared → entity` transport edge (auth is cross-cutting). `api/rest/client` and `api/graphql/client` also import `ui/queries/refresh-access-token.query`, sharing one TanStack query so concurrent 401s trigger a single refresh. +> +> On the GraphQL side the refresh loop is `@urql/exchange-auth`: `didAuthError` matches the `TOKEN_EXPIRED` catalogue code, `refreshAuth` awaits that shared TanStack query, and the exchange pauses and replays the held operations itself — one refresh across concurrent operations, then a single replay. A refresh that throws, or an expiry that persists after the replay, redirects to `/login`. ## The registry diff --git a/dev/knowledge/frontend/date-rendering.md b/dev/knowledge/frontend/date-rendering.md index a1a878c3c35..953bf69df70 100644 --- a/dev/knowledge/frontend/date-rendering.md +++ b/dev/knowledge/frontend/date-rendering.md @@ -26,8 +26,8 @@ and never a hardcoded pattern. - `DatePreferencesProvider` (`entities/preferences/ui/date-preferences-provider.tsx`) fills the context from `useGetEffectivePreferences()`, but **only once the user is authenticated** — it returns its children unchanged when logged out. The query needs auth, yet the app also renders for - logged-out users (`allow_anonymous_access`), so an ungated fetch would 401 and Apollo's error link - would bounce them to `/login`. Gating by *mount* (not react-query `enabled`) keeps the query hook + logged-out users (`allow_anonymous_access`), so an ungated fetch would 401 and the transport's + error routing would bounce them to `/login`. Gating by *mount* (not react-query `enabled`) keeps the query hook auth-agnostic. `RequireAuth` is **not** a sufficient gate here — it renders for anonymous users too. - When no provider is mounted, or a preference's `source` is `"DEFAULT"` (nothing set), formatting falls back to the **browser locale + zone** (`toLocaleString`) — never a hardcoded pattern. So diff --git a/dev/knowledge/frontend/entities-structure.md b/dev/knowledge/frontend/entities-structure.md index faf1dd489c2..e273b72a4d1 100644 --- a/dev/knowledge/frontend/entities-structure.md +++ b/dev/knowledge/frontend/entities-structure.md @@ -287,10 +287,7 @@ Do not write a one-off `resolveUuid` function. ### api-layer `graphqlClient` call conventions -Two defaults of the shared `graphqlClient` are easy to override by mistake in `api/*-from-api.ts` files: - -- **Don't pass `fetchPolicy`.** The client already defaults to `no-cache` (TanStack Query owns caching, not Apollo). Passing `fetchPolicy: "no-cache"` is redundant — omit it. -- **Mutations already surface their own error toast.** The client's error link shows a toast for a failed request. If the caller *also* renders one (e.g. in a `useMutation` `onError`), the user sees two. To let the caller own the toast, suppress the client's with the mutation `context`: +- **Mutations already surface their own error toast.** The client routes a failed request to a toast. If the caller *also* renders one (e.g. in a `useMutation` `onError`), the user sees two. To let the caller own the toast, suppress the client's with the mutation `context`: ```ts graphqlClient.mutate({ @@ -362,10 +359,10 @@ See `dev/guidelines/frontend/naming-conventions.md` for the full naming conventi ## GraphQL transport vs server-state hooks -Apollo Client is kept as the GraphQL transport (auth links, error handling, retry) only. All server-state hooks are TanStack Query. Do not use `useQuery` / `useMutation` / `useLazyQuery` from `@apollo/client` — they were removed in 2026-05. +`@urql/core` is the GraphQL transport (auth, error routing, token refresh, uploads) only — there is no GraphQL-layer cache and there are no GraphQL hooks. All server-state hooks are TanStack Query. -- `@apollo/client` imports are allowed **only** in `src/app/app.tsx` (for `ApolloProvider`) and `src/shared/api/graphql/graphqlClientApollo.tsx` (client construction), plus `gql` template-tag imports in `entities/*/api/` files. -- React hooks (`useQuery`, `useMutation`, etc.) from `@apollo/client` are forbidden throughout the codebase. +- `@urql/core` imports are allowed **only** in `src/shared/api/graphql/client.ts` (tests may import it to build documents). There is no provider to wrap the app in — the client is used imperatively. +- Everything else imports `graphql` and `graphqlClient` from `@/shared/api/graphql/client`, so the transport library stays swappable. `graphql()` covers both cases: a template literal gives a typed document, and a runtime-assembled string (the `jsonToGraphQLQuery` sites) gives an untyped one. - Use `useQuery` / `useMutation` from `@tanstack/react-query` (typically via the pattern in `ui/queries/`) for all data fetching. ### Mutation invalidation diff --git a/dev/knowledge/frontend/request-priority.md b/dev/knowledge/frontend/request-priority.md index 6ab46c2301f..373de2024ec 100644 --- a/dev/knowledge/frontend/request-priority.md +++ b/dev/knowledge/frontend/request-priority.md @@ -21,16 +21,17 @@ trusted, and why the header is stamped at the transport boundary instead of at c - `PRIORITY_HEADER = 'X-Priority'`. - `resolvePriority(value)` — normalizes an untyped per-request value: returns `'low'` only for exactly `'low'`, everything else (`'medium'`, `undefined`, garbage) → `'high'`. - Each transport runs its value through this before writing the header, so a stray or - legacy value cannot leak an out-of-contract priority. + Any transport that accepts a per-request priority runs its value through this before + writing the header, so a stray or legacy value cannot leak an out-of-contract priority. + A transport that only ever emits the default writes `DEFAULT_PRIORITY` directly. ## Four injection points (default `high`) The header is stamped at each transport entry, not at call sites: -1. **Apollo GraphQL** — `priorityLink` (`setContext`) in - `shared/api/graphql/graphqlClientApollo.tsx`, inserted into the link chain. Uploads - ride the same terminating `createUploadLink`, so they inherit it for free. +1. **GraphQL (urql)** — the `fetchOptions` handed to each `Client` in + `shared/api/graphql/client.ts`, so every operation on that client carries it. Uploads ride + the same terminating `fetchExchange`, so they inherit it for free. 2. **REST (`openapi-fetch`)** — `authMiddleware.onRequest` in `shared/api/rest/client.ts`. The header is set before the `Request` clone captured for 401 replay, so replay preserves it. @@ -40,22 +41,19 @@ The header is stamped at each transport entry, not at call sites: 4. **GraphiQL fetcher** — `shared/libs/graphiql/use-graphiql-fetcher.ts` sets `X-Priority: high` on its raw sandbox fetch. -The header survives both 401-refresh replay paths (Apollo `...oldHeaders` spread; REST -stored clone) and the file-upload rebuild path. +The header survives both 401-refresh replay paths (urql's `authExchange` replays the operation +with its context, including `fetchOptions`; REST replays a stored clone) and the file-upload +rebuild path. ## Opting a request down to `low` (one convention per transport) The default is `high`; an undeclared request needs no change. To demote a single request, declare it at the call site using its transport's idiom: -- **GraphQL** — `context: { priority: 'low' }` on the operation. - **REST** — pre-set the header via `params: { header: { 'X-Priority': 'low' } }` (openapi-fetch's `options` is read-only and exposes no custom field, so the header itself is the opt-in surface). -- **Raw fetch** — the `{ priority: 'low' }` option argument to `fetchUrl`. - -No helper wraps these: the v1 `low` set is empty (no production caller demotes yet), so a -helper would serve only tests (YAGNI). The mechanism plus convention is the deliverable. +- **GraphQL** has no per-operation opt-down. ## Watched status stays `high` diff --git a/frontend/app/package.json b/frontend/app/package.json index c370508c22b..c1199d5c803 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -32,7 +32,6 @@ "check:error-bindings": "node scripts/generate-error-bindings.mjs --check" }, "dependencies": { - "@apollo/client": "3.13.8", "@codemirror/commands": "^6.10.4", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/language": "^6.12.4", @@ -58,8 +57,9 @@ "@tanstack/react-query-devtools": "^5.101.1", "@tanstack/react-table": "^8.21.3", "@uiw/react-color": "^2.10.3", + "@urql/core": "^6.0.3", + "@urql/exchange-auth": "^3.0.0", "@xyflow/react": "^12.11.1", - "apollo-upload-client": "18.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cm6-graphql": "^0.2.1", @@ -114,7 +114,6 @@ "@playwright/test": "1.61.1", "@rolldown/plugin-babel": "^0.2.3", "@tailwindcss/vite": "catalog:", - "@types/apollo-upload-client": "18.0.1", "@types/dagre": "^0.7.54", "@types/node": "catalog:", "@types/prismjs": "^1.26.6", diff --git a/frontend/app/src/app/app.tsx b/frontend/app/src/app/app.tsx index 3e16246f036..64509dc5c52 100644 --- a/frontend/app/src/app/app.tsx +++ b/frontend/app/src/app/app.tsx @@ -1,4 +1,3 @@ -import { ApolloProvider } from "@apollo/client"; import { addCollection } from "@iconify-icon/react"; import mdiIcons from "@iconify-json/mdi/icons.json" with { type: "json" }; import { QueryClientProvider } from "@tanstack/react-query"; @@ -10,7 +9,6 @@ import { RouterProvider } from "react-router"; import { TanStackQueryDevtools } from "@/app/devtools"; import { router } from "@/app/router"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; import { queryClient } from "@/shared/api/rest/client"; import { ErrorBoundaryApp } from "@/shared/components/errors/error-boundary-app"; import { store } from "@/shared/stores"; @@ -30,15 +28,13 @@ export function App() { - - - - - - - - - + + + + + + + diff --git a/frontend/app/src/entities/authentication/domain/use-cases/refresh-access-token.ts b/frontend/app/src/entities/authentication/domain/use-cases/refresh-access-token.ts index 38c2bd1222e..9ebbe79f227 100644 --- a/frontend/app/src/entities/authentication/domain/use-cases/refresh-access-token.ts +++ b/frontend/app/src/entities/authentication/domain/use-cases/refresh-access-token.ts @@ -9,12 +9,6 @@ import { export type RefreshAccessToken = () => Promise; -// Throws on every failure mode (missing refresh token, API error). The caller -// is responsible for handling the failure — `retryWithRefreshedToken` in -// graphqlClientApollo.tsx catches the rejection and calls `redirectToLogin`. -// Previously this function did its own `window.location.reload()`, which -// dropped in-flight React Query state and double-navigated when the catch -// site also redirected. export const refreshAccessToken: RefreshAccessToken = async () => { const refreshToken = getRefreshToken(); diff --git a/frontend/app/src/entities/branches/api/create-branch-from-api.ts b/frontend/app/src/entities/branches/api/create-branch-from-api.ts index 7ecd3e4936f..52779b4e1a9 100644 --- a/frontend/app/src/entities/branches/api/create-branch-from-api.ts +++ b/frontend/app/src/entities/branches/api/create-branch-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const BRANCH_CREATE = graphql(` mutation BRANCH_CREATE($name: String!, $description: String, $sync_with_git: Boolean) { diff --git a/frontend/app/src/entities/branches/api/delete-branch-from-api.ts b/frontend/app/src/entities/branches/api/delete-branch-from-api.ts index c9940ac9ad5..c9ab7222904 100644 --- a/frontend/app/src/entities/branches/api/delete-branch-from-api.ts +++ b/frontend/app/src/entities/branches/api/delete-branch-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const BRANCH_DELETE = graphql(` mutation BRANCH_DELETE($name: String, $deleteFromGit: Boolean) { diff --git a/frontend/app/src/entities/branches/api/get-branch-action-state-from-api.ts b/frontend/app/src/entities/branches/api/get-branch-action-state-from-api.ts index efdbbbf9ed3..a303e60de8e 100644 --- a/frontend/app/src/entities/branches/api/get-branch-action-state-from-api.ts +++ b/frontend/app/src/entities/branches/api/get-branch-action-state-from-api.ts @@ -1,7 +1,5 @@ -import { graphql } from "gql.tada"; - +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { StateType } from "@/shared/api/graphql/generated/types"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; const GET_BRANCH_ACTION_STATE = graphql(` query GET_BRANCH_ACTION_STATE($branch: String!, $workflow: [String], $state: [StateType]) { @@ -25,6 +23,5 @@ export function getBranchActionStateFromApi(params: GetBranchActionStateFromApiP workflow: [...params.workflow], state: [...params.state], }, - fetchPolicy: "no-cache", }); } diff --git a/frontend/app/src/entities/branches/api/get-branch-details-from-api.ts b/frontend/app/src/entities/branches/api/get-branch-details-from-api.ts index c035728e116..993fb97c922 100644 --- a/frontend/app/src/entities/branches/api/get-branch-details-from-api.ts +++ b/frontend/app/src/entities/branches/api/get-branch-details-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; const GET_BRANCH_DETAILS = graphql(` diff --git a/frontend/app/src/entities/branches/api/get-branches-count-from-api.ts b/frontend/app/src/entities/branches/api/get-branches-count-from-api.ts index d652b75fe1d..266b284f58a 100644 --- a/frontend/app/src/entities/branches/api/get-branches-count-from-api.ts +++ b/frontend/app/src/entities/branches/api/get-branches-count-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_BRANCHES_COUNT = graphql(` query GetBranchesCount($nameValue: String, $partialMatch: Boolean, $statusValue: BranchStatus, $createdById: ID, $branchedFromAfter: DateTime, $branchedFromBefore: DateTime, $createdAtAfter: DateTime, $createdAtBefore: DateTime, $updatedAtAfter: DateTime, $updatedAtBefore: DateTime) { diff --git a/frontend/app/src/entities/branches/api/get-branches-from-api.ts b/frontend/app/src/entities/branches/api/get-branches-from-api.ts index 96f69ab0967..5b4c052ab30 100644 --- a/frontend/app/src/entities/branches/api/get-branches-from-api.ts +++ b/frontend/app/src/entities/branches/api/get-branches-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; export const BRANCHES_PER_PAGE = 40; diff --git a/frontend/app/src/entities/branches/api/merge-branch-from-api.ts b/frontend/app/src/entities/branches/api/merge-branch-from-api.ts index 73235dbe194..29d5f65afa7 100644 --- a/frontend/app/src/entities/branches/api/merge-branch-from-api.ts +++ b/frontend/app/src/entities/branches/api/merge-branch-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const BRANCH_MERGE = graphql(` mutation BRANCH_MERGE($name: String) { diff --git a/frontend/app/src/entities/branches/api/rebase-branch-from-api.ts b/frontend/app/src/entities/branches/api/rebase-branch-from-api.ts index 042190b2fde..1c489dfbf42 100644 --- a/frontend/app/src/entities/branches/api/rebase-branch-from-api.ts +++ b/frontend/app/src/entities/branches/api/rebase-branch-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const BRANCH_REBASE = graphql(` mutation BRANCH_REBASE($name: String, $waitUntilCompletion: Boolean!) { diff --git a/frontend/app/src/entities/branches/api/validate-branch-from-api.ts b/frontend/app/src/entities/branches/api/validate-branch-from-api.ts index 4be43b33361..6288ffdee76 100644 --- a/frontend/app/src/entities/branches/api/validate-branch-from-api.ts +++ b/frontend/app/src/entities/branches/api/validate-branch-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const BRANCH_VALIDATE = graphql(` mutation BRANCH_VALIDATE($name: String) { diff --git a/frontend/app/src/entities/diff/api/get-artifact-content-diff-from-api.ts b/frontend/app/src/entities/diff/api/get-artifact-content-diff-from-api.ts index 92246dd03c0..8e6583269e0 100644 --- a/frontend/app/src/entities/diff/api/get-artifact-content-diff-from-api.ts +++ b/frontend/app/src/entities/diff/api/get-artifact-content-diff-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const GET_ARTIFACT_THREADS = graphql(` query GET_ARTIFACT_THREADS($changeIds: [ID!]) { @@ -52,6 +50,5 @@ export function getArtifactContentDiffFromApi(params: GetArtifactContentDiffFrom variables: { changeIds: [params.proposedChangeId], }, - fetchPolicy: "no-cache", }); } diff --git a/frontend/app/src/entities/diff/api/get-check-details-from-api.ts b/frontend/app/src/entities/diff/api/get-check-details-from-api.ts index 6dfcbf080f7..8669fc56e55 100644 --- a/frontend/app/src/entities/diff/api/get-check-details-from-api.ts +++ b/frontend/app/src/entities/diff/api/get-check-details-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_CHECK_DETAILS = graphql(` query GET_CHECK_DETAILS($id: ID!) { diff --git a/frontend/app/src/entities/diff/api/get-diff-comments-from-api.ts b/frontend/app/src/entities/diff/api/get-diff-comments-from-api.ts index b7518654fd7..87ec7746b7f 100644 --- a/frontend/app/src/entities/diff/api/get-diff-comments-from-api.ts +++ b/frontend/app/src/entities/diff/api/get-diff-comments-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const GET_OBJECT_THREAD_COMMENTS = graphql(` query GET_OBJECT_THREAD_COMMENTS($changeIds: [ID!], $objectPath: String) { @@ -50,6 +48,5 @@ export function getDiffCommentsFromApi(params: GetDiffCommentsFromApiParams) { changeIds: [params.proposedChangeId], objectPath: params.objectPath, }, - fetchPolicy: "no-cache", }); } diff --git a/frontend/app/src/entities/diff/api/get-diff-thread-from-api.ts b/frontend/app/src/entities/diff/api/get-diff-thread-from-api.ts index 83416646711..c8921e3e23b 100644 --- a/frontend/app/src/entities/diff/api/get-diff-thread-from-api.ts +++ b/frontend/app/src/entities/diff/api/get-diff-thread-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const GET_OBJECT_THREADS = graphql(` query GET_OBJECT_THREADS($changeIds: [ID!], $objectPath: String) { @@ -42,6 +40,5 @@ export function getDiffThreadFromApi(params: GetDiffThreadFromApiParams) { changeIds: [params.proposedChangeId], objectPath: params.objectPath, }, - fetchPolicy: "no-cache", }); } diff --git a/frontend/app/src/entities/diff/api/get-diff-tree-from-api.ts b/frontend/app/src/entities/diff/api/get-diff-tree-from-api.ts index 497ca017050..9093d2887bc 100644 --- a/frontend/app/src/entities/diff/api/get-diff-tree-from-api.ts +++ b/frontend/app/src/entities/diff/api/get-diff-tree-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { BranchContextParams, PaginationParams } from "@/shared/api/types"; const DIFF_TREE_QUERY = graphql(` diff --git a/frontend/app/src/entities/diff/api/get-diff-tree-summary-from-api.ts b/frontend/app/src/entities/diff/api/get-diff-tree-summary-from-api.ts index 2dfaa10d73c..4795951f622 100644 --- a/frontend/app/src/entities/diff/api/get-diff-tree-summary-from-api.ts +++ b/frontend/app/src/entities/diff/api/get-diff-tree-summary-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_PROPOSED_CHANGES_DIFF_SUMMARY = graphql(` query GET_DIFF_TREE_SUMMARY($branch: String, $filters: DiffTreeQueryFilters, $proposedChangeId: String) { diff --git a/frontend/app/src/entities/diff/api/get-file-content-diff-from-api.ts b/frontend/app/src/entities/diff/api/get-file-content-diff-from-api.ts index 74929efdb56..52b913bea5e 100644 --- a/frontend/app/src/entities/diff/api/get-file-content-diff-from-api.ts +++ b/frontend/app/src/entities/diff/api/get-file-content-diff-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const GET_FILE_THREADS = graphql(` query GET_FILE_THREADS($changeIds: [ID!]) { @@ -60,6 +58,5 @@ export function getFileContentDiffFromApi(params: GetFileContentDiffFromApiParam variables: { changeIds: [params.proposedChangeId], }, - fetchPolicy: "no-cache", }); } diff --git a/frontend/app/src/entities/diff/api/get-validator-details-from-api.ts b/frontend/app/src/entities/diff/api/get-validator-details-from-api.ts index c83edf21754..a60bcda1470 100644 --- a/frontend/app/src/entities/diff/api/get-validator-details-from-api.ts +++ b/frontend/app/src/entities/diff/api/get-validator-details-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_VALIDATOR_DETAILS = graphql(` query GET_VALIDATOR_DETAILS($ids: [ID!], $checksOffset: Int, $checksLimit: Int) { diff --git a/frontend/app/src/entities/diff/api/get-validators-from-api.ts b/frontend/app/src/entities/diff/api/get-validators-from-api.ts index b73e1682438..bc9b4c5cef0 100644 --- a/frontend/app/src/entities/diff/api/get-validators-from-api.ts +++ b/frontend/app/src/entities/diff/api/get-validators-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_VALIDATORS = graphql(` query GET_CORE_VALIDATORS($id: ID!) { diff --git a/frontend/app/src/entities/diff/api/resolve-conflict-from-api.ts b/frontend/app/src/entities/diff/api/resolve-conflict-from-api.ts index 22eaed14df1..efbd0e0a22c 100644 --- a/frontend/app/src/entities/diff/api/resolve-conflict-from-api.ts +++ b/frontend/app/src/entities/diff/api/resolve-conflict-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const RESOLVE_CONFLICT = graphql(` mutation RESOLVE_CONFLICT($id: String, $selection: ConflictSelection) { diff --git a/frontend/app/src/entities/diff/api/run-check-from-api.ts b/frontend/app/src/entities/diff/api/run-check-from-api.ts index 38d0f3d88bc..c580ef744a6 100644 --- a/frontend/app/src/entities/diff/api/run-check-from-api.ts +++ b/frontend/app/src/entities/diff/api/run-check-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const RUN_CHECK = graphql(` mutation RUN_CHECK($proposedChangeId: String!, $checkType: CheckType) { diff --git a/frontend/app/src/entities/diff/api/update-diff-from-api.ts b/frontend/app/src/entities/diff/api/update-diff-from-api.ts index 706de572ce6..c2773282bf0 100644 --- a/frontend/app/src/entities/diff/api/update-diff-from-api.ts +++ b/frontend/app/src/entities/diff/api/update-diff-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const DIFF_UPDATE = graphql(` mutation DIFF_UPDATE($branchName: String!, $waitUntilCompletion: Boolean) { diff --git a/frontend/app/src/entities/events/api/get-events-from-api.ts b/frontend/app/src/entities/events/api/get-events-from-api.ts index 0ab5ee0268b..e8c2eced828 100644 --- a/frontend/app/src/entities/events/api/get-events-from-api.ts +++ b/frontend/app/src/entities/events/api/get-events-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import { DEFAULT_PAGE_SIZE } from "@/shared/utils/pagination"; const EVENTS_QUERY = graphql(` diff --git a/frontend/app/src/entities/generators/api/run-generator-from-api.ts b/frontend/app/src/entities/generators/api/run-generator-from-api.ts index 56e5f050378..cc5d09d0660 100644 --- a/frontend/app/src/entities/generators/api/run-generator-from-api.ts +++ b/frontend/app/src/entities/generators/api/run-generator-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; const generatorRunMutation = graphql(` diff --git a/frontend/app/src/entities/groups/api/get-groups-from-api.ts b/frontend/app/src/entities/groups/api/get-groups-from-api.ts index 5a68a138a25..c02ff2c5051 100644 --- a/frontend/app/src/entities/groups/api/get-groups-from-api.ts +++ b/frontend/app/src/entities/groups/api/get-groups-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery, VariableType } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; const getGroupsQuery = ({ objectKind }: { objectKind: string }) => { @@ -54,7 +53,7 @@ export function getGroupsFromApi({ branchName, atDate, }: GetGroupsFromApiParams) { - const query = gql(getGroupsQuery({ objectKind })); + const query = graphql(getGroupsQuery({ objectKind })); return graphqlClient.query({ query, diff --git a/frontend/app/src/entities/ipam/ip-addresses/api/get-ip-address-list-from-api.ts b/frontend/app/src/entities/ipam/ip-addresses/api/get-ip-address-list-from-api.ts index c5af82fc7dc..af61d975a35 100644 --- a/frontend/app/src/entities/ipam/ip-addresses/api/get-ip-address-list-from-api.ts +++ b/frontend/app/src/entities/ipam/ip-addresses/api/get-ip-address-list-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { addAttributesToRequest, addFiltersToRequest, @@ -126,7 +125,7 @@ export function getIpAddressListWithAvailabilityFromApi({ const graphqlQuery = getIpAddressListWithAvailabilityGraphQLQuery(params); return graphqlClient.query({ - query: gql(graphqlQuery), + query: graphql(graphqlQuery), context: { branch: branchName, date: atDate, @@ -142,7 +141,7 @@ export function getIpAddressListWithoutAvailabilityFromApi({ const graphqlQuery = getIpAddressListWithoutAvailabilityGraphQLQuery(params); return graphqlClient.query({ - query: gql(graphqlQuery), + query: graphql(graphqlQuery), context: { branch: branchName, date: atDate, diff --git a/frontend/app/src/entities/ipam/ip-addresses/api/get-next-ip-address-available-from-api.ts b/frontend/app/src/entities/ipam/ip-addresses/api/get-next-ip-address-available-from-api.ts index 1e7a308e0ba..1e41024ac46 100644 --- a/frontend/app/src/entities/ipam/ip-addresses/api/get-next-ip-address-available-from-api.ts +++ b/frontend/app/src/entities/ipam/ip-addresses/api/get-next-ip-address-available-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; const NEXT_IP_ADDRESS_QUERY = graphql(` diff --git a/frontend/app/src/entities/ipam/ip-namespaces/api/get-ip-namespace-list-from-api.ts b/frontend/app/src/entities/ipam/ip-namespaces/api/get-ip-namespace-list-from-api.ts index e0e0cfdd2c5..cf7ad0bc1c2 100644 --- a/frontend/app/src/entities/ipam/ip-namespaces/api/get-ip-namespace-list-from-api.ts +++ b/frontend/app/src/entities/ipam/ip-namespaces/api/get-ip-namespace-list-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { addFiltersToRequest } from "@/shared/api/graphql/utils"; import type { ContextParams, PaginationParams } from "@/shared/api/types"; import { DEFAULT_PAGE_SIZE } from "@/shared/utils/pagination"; @@ -20,7 +19,7 @@ export async function getIpNamespaceListFromApi({ branchName, atDate, }: GetIpNamespaceListFromApiParams) { - const query = gql( + const query = graphql( jsonToGraphQLQuery({ query: { __name: `GetObjects${IP_NAMESPACE_GENERIC}`, diff --git a/frontend/app/src/entities/ipam/ip-prefixes/api/get-ip-prefix-list-from-api.ts b/frontend/app/src/entities/ipam/ip-prefixes/api/get-ip-prefix-list-from-api.ts index 4ee1ac5652f..22c19855cb6 100644 --- a/frontend/app/src/entities/ipam/ip-prefixes/api/get-ip-prefix-list-from-api.ts +++ b/frontend/app/src/entities/ipam/ip-prefixes/api/get-ip-prefix-list-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { addAttributesToRequest, addFiltersToRequest, @@ -55,7 +54,7 @@ export async function getIpPrefixListFromApi({ relationships, }); - const query = gql(queryString); + const query = graphql(queryString); return graphqlClient.query({ query, context: { diff --git a/frontend/app/src/entities/ipam/ip-prefixes/api/get-next-ip-prefix-available-from-api.ts b/frontend/app/src/entities/ipam/ip-prefixes/api/get-next-ip-prefix-available-from-api.ts index 57db1d9e936..a977eb69267 100644 --- a/frontend/app/src/entities/ipam/ip-prefixes/api/get-next-ip-prefix-available-from-api.ts +++ b/frontend/app/src/entities/ipam/ip-prefixes/api/get-next-ip-prefix-available-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; const NEXT_IP_PREFIX_QUERY = graphql(` diff --git a/frontend/app/src/entities/ipam/ipam-tree/api/get-ipam-tree-nodes-by-parent-from-api.ts b/frontend/app/src/entities/ipam/ipam-tree/api/get-ipam-tree-nodes-by-parent-from-api.ts index fab6f12982a..72cced5ecf2 100644 --- a/frontend/app/src/entities/ipam/ipam-tree/api/get-ipam-tree-nodes-by-parent-from-api.ts +++ b/frontend/app/src/entities/ipam/ipam-tree/api/get-ipam-tree-nodes-by-parent-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams, PaginationParams } from "@/shared/api/types"; const GET_IPAM_TREE_NODES = graphql(` diff --git a/frontend/app/src/entities/navigation/api/search-from-api.ts b/frontend/app/src/entities/navigation/api/search-from-api.ts index 9bebea6f8df..ee35ca03e64 100644 --- a/frontend/app/src/entities/navigation/api/search-from-api.ts +++ b/frontend/app/src/entities/navigation/api/search-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; const SEARCH = graphql(` diff --git a/frontend/app/src/entities/nodes/convert/api/convert-object-from-api.ts b/frontend/app/src/entities/nodes/convert/api/convert-object-from-api.ts index 1486a8559f2..d2b3423c479 100644 --- a/frontend/app/src/entities/nodes/convert/api/convert-object-from-api.ts +++ b/frontend/app/src/entities/nodes/convert/api/convert-object-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; const CONVERT_OBJECT_MUTATION = graphql(` diff --git a/frontend/app/src/entities/nodes/convert/api/get-object-convert-fields-mapping-from-api.ts b/frontend/app/src/entities/nodes/convert/api/get-object-convert-fields-mapping-from-api.ts index 9c049eb036e..50774881c7e 100644 --- a/frontend/app/src/entities/nodes/convert/api/get-object-convert-fields-mapping-from-api.ts +++ b/frontend/app/src/entities/nodes/convert/api/get-object-convert-fields-mapping-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; const GET_FIELDS_MAPPING = graphql(` diff --git a/frontend/app/src/entities/nodes/hierarchy/api/get-object-ancestors-from-api.ts b/frontend/app/src/entities/nodes/hierarchy/api/get-object-ancestors-from-api.ts index 614c7295a71..cd795e5644d 100644 --- a/frontend/app/src/entities/nodes/hierarchy/api/get-object-ancestors-from-api.ts +++ b/frontend/app/src/entities/nodes/hierarchy/api/get-object-ancestors-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; export interface GetObjectAncestorsFromApiParams extends ContextParams { @@ -68,7 +67,7 @@ export const getObjectAncestorsFromApi = async ({ const query = getObjectAncestorsQuery({ objectKind, objectId }); return graphqlClient.query({ - query: gql(query), + query: graphql(query), context: { branch: branchName, date: atDate, diff --git a/frontend/app/src/entities/nodes/hierarchy/api/get-tree-nodes-by-parent-from-api.ts b/frontend/app/src/entities/nodes/hierarchy/api/get-tree-nodes-by-parent-from-api.ts index d30e6bdbcfc..6e4e68fc0e6 100644 --- a/frontend/app/src/entities/nodes/hierarchy/api/get-tree-nodes-by-parent-from-api.ts +++ b/frontend/app/src/entities/nodes/hierarchy/api/get-tree-nodes-by-parent-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams, PaginationParams } from "@/shared/api/types"; export interface GetTreeNodesByParentQueryParams extends PaginationParams { @@ -49,7 +48,7 @@ export function GetTreeNodesByParentFromApi({ ...params }: GetTreeNodesByParentFromApiParams) { return graphqlClient.query({ - query: gql(GetTreeNodesByParentQuery(params)), + query: graphql(GetTreeNodesByParentQuery(params)), context: { branch: branchName, date: atDate, diff --git a/frontend/app/src/entities/nodes/object/api/create-object-from-api.ts b/frontend/app/src/entities/nodes/object/api/create-object-from-api.ts index 9658cda0808..d0a7c55923d 100644 --- a/frontend/app/src/entities/nodes/object/api/create-object-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/create-object-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery, VariableType } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; export interface CreateObjectFromApiParams extends BranchContextParams { @@ -40,7 +39,7 @@ export function createObjectFromApi({ }); return graphqlClient.mutate({ - mutation: gql(mutation), + mutation: graphql(mutation), variables: file ? { file } : undefined, context: { branch: branchName, diff --git a/frontend/app/src/entities/nodes/object/api/delete-object-from-api.ts b/frontend/app/src/entities/nodes/object/api/delete-object-from-api.ts index 082f003465c..377b614ac72 100644 --- a/frontend/app/src/entities/nodes/object/api/delete-object-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/delete-object-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; const getDeleteObjectQuery = (kind: string, objectId: string) => { @@ -26,7 +25,7 @@ export function deleteObjectFromApi({ atDate, }: ContextParams & { objectKind: string; objectId: string }) { return graphqlClient.mutate({ - mutation: gql(getDeleteObjectQuery(objectKind, objectId)), + mutation: graphql(getDeleteObjectQuery(objectKind, objectId)), context: { branch: branchName, date: atDate, diff --git a/frontend/app/src/entities/nodes/object/api/delete-objects-from-api.ts b/frontend/app/src/entities/nodes/object/api/delete-objects-from-api.ts index 72a5380b677..3bb7cb1f2eb 100644 --- a/frontend/app/src/entities/nodes/object/api/delete-objects-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/delete-objects-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; export interface ObjectParam { @@ -42,7 +41,7 @@ export interface DeleteObjectsFromApiParams extends BranchContextParams { export function deleteObjectsFromApi({ objects, branchName, context }: DeleteObjectsFromApiParams) { return graphqlClient.mutate({ - mutation: gql(getDeleteObjectsQuery(objects)), + mutation: graphql(getDeleteObjectsQuery(objects)), context: { branch: branchName, ...context, diff --git a/frontend/app/src/entities/nodes/object/api/get-display-label-from-api.ts b/frontend/app/src/entities/nodes/object/api/get-display-label-from-api.ts index cbbd610aa9c..d2444cfcc24 100644 --- a/frontend/app/src/entities/nodes/object/api/get-display-label-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/get-display-label-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; const getNodeLabelQuery = ({ objectId, kind }: { objectId?: string | null; kind: string }) => { @@ -34,7 +33,7 @@ export function getNodeLabelFromApi({ kind: string; } & ContextParams) { return graphqlClient.query({ - query: gql(getNodeLabelQuery({ objectId, kind })), + query: graphql(getNodeLabelQuery({ objectId, kind })), context: { branch: branchName, date: atDate, diff --git a/frontend/app/src/entities/nodes/object/api/get-node-metadata-from-api.ts b/frontend/app/src/entities/nodes/object/api/get-node-metadata-from-api.ts index 16f6bec617a..7201f6127d0 100644 --- a/frontend/app/src/entities/nodes/object/api/get-node-metadata-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/get-node-metadata-from-api.ts @@ -1,8 +1,7 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { nodeMetadataFragment } from "@/shared/api/graphql/fragments"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; import type { ContextParams } from "@/shared/api/types"; export interface GetNodeMetadataQueryParams { @@ -23,7 +22,7 @@ const getNodeMetadataQuery = ({ objectId, objectKind }: GetNodeMetadataQueryPara }, }; - return gql(jsonToGraphQLQuery(query)); + return graphql(jsonToGraphQLQuery(query)); }; export interface GetNodeMetadataFromApiParams extends ContextParams { diff --git a/frontend/app/src/entities/nodes/object/api/get-object-for-editing-from-api.ts b/frontend/app/src/entities/nodes/object/api/get-object-for-editing-from-api.ts index 4bc6f0e5516..f95134c3cd0 100644 --- a/frontend/app/src/entities/nodes/object/api/get-object-for-editing-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/get-object-for-editing-from-api.ts @@ -1,8 +1,7 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { nodeCoreFragment } from "@/shared/api/graphql/fragments"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; import { addAttributesToRequest, addRelationshipsToRequest } from "@/shared/api/graphql/utils"; import type { ContextParams } from "@/shared/api/types"; import { getRelationshipsForForm } from "@/shared/components/form/utils/getRelationshipsForForm"; @@ -76,11 +75,10 @@ export async function getObjectForEditingFromApi({ }); return graphqlClient.query({ - query: gql(queryString), + query: graphql(queryString), context: { branch: branchName, date: atDate, }, - fetchPolicy: "no-cache", }); } diff --git a/frontend/app/src/entities/nodes/object/api/get-object-from-api.ts b/frontend/app/src/entities/nodes/object/api/get-object-from-api.ts index 12187c30d96..ac9c4827d83 100644 --- a/frontend/app/src/entities/nodes/object/api/get-object-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/get-object-from-api.ts @@ -1,8 +1,7 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { nodeCoreFragment } from "@/shared/api/graphql/fragments"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; import { addAttributesToRequest, addRelationshipsToRequest } from "@/shared/api/graphql/utils"; import type { ContextParams } from "@/shared/api/types"; @@ -23,7 +22,7 @@ const getObjectQuery = ({ relationships, relationshipFragment, }: GetObjectQueryParams) => { - return gql( + return graphql( jsonToGraphQLQuery({ query: { __name: `GetObject${schemaKind}`, diff --git a/frontend/app/src/entities/nodes/object/api/get-objects-count-from-api.ts b/frontend/app/src/entities/nodes/object/api/get-objects-count-from-api.ts index 275c367f67e..86a7f0b9673 100644 --- a/frontend/app/src/entities/nodes/object/api/get-objects-count-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/get-objects-count-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { addFiltersToRequest } from "@/shared/api/graphql/utils"; import type { ContextParams } from "@/shared/api/types"; @@ -25,7 +24,7 @@ const getObjectsCountQuery = ({ objectKind, filters }: getObjectsCountQueryParam }, }; - return gql(jsonToGraphQLQuery(query)); + return graphql(jsonToGraphQLQuery(query)); }; export interface GetObjectsCountFromApiParams extends ContextParams { diff --git a/frontend/app/src/entities/nodes/object/api/get-objects-from-api.ts b/frontend/app/src/entities/nodes/object/api/get-objects-from-api.ts index 24665b4db40..b47558988b7 100644 --- a/frontend/app/src/entities/nodes/object/api/get-objects-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/get-objects-from-api.ts @@ -1,8 +1,7 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { nodeCoreFragment } from "@/shared/api/graphql/fragments"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; import { type AddAttributesToRequestOptions, addAttributesToRequest, @@ -39,7 +38,7 @@ const getObjectsQuery = ({ attributesOptions, relationshipsOptions, }: GetObjectsQueryParams) => { - return gql( + return graphql( jsonToGraphQLQuery({ query: { __name: `GetObjects${schemaKind}`, diff --git a/frontend/app/src/entities/nodes/object/api/update-object-from-api.ts b/frontend/app/src/entities/nodes/object/api/update-object-from-api.ts index f1afd2fb7b4..e66ce9c6547 100644 --- a/frontend/app/src/entities/nodes/object/api/update-object-from-api.ts +++ b/frontend/app/src/entities/nodes/object/api/update-object-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery, VariableType } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; import { RELATIONSHIP_BULK_ADD_PREFIX, @@ -123,7 +122,7 @@ export function updateObjectFromApi({ }); return graphqlClient.mutate({ - mutation: gql(mutation), + mutation: graphql(mutation), variables: file ? { file } : undefined, context: { branch: branchName, diff --git a/frontend/app/src/entities/nodes/profiles/api/get-profiles-from-api.ts b/frontend/app/src/entities/nodes/profiles/api/get-profiles-from-api.ts index 3102ab2e61a..ac785413f00 100644 --- a/frontend/app/src/entities/nodes/profiles/api/get-profiles-from-api.ts +++ b/frontend/app/src/entities/nodes/profiles/api/get-profiles-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { addAttributesToRequest, addRelationshipsToRequest } from "@/shared/api/graphql/utils"; import type { ContextParams } from "@/shared/api/types"; @@ -45,7 +44,7 @@ export function getProfilesFromApi({ const getProfilesQueryString = buildGetProfilesQuery(profileSchemas); return graphqlClient.query({ - query: gql(getProfilesQueryString), + query: graphql(getProfilesQueryString), context: { branch: branchName, date: atDate, diff --git a/frontend/app/src/entities/nodes/relationships/api/add-relationships-from-api.ts b/frontend/app/src/entities/nodes/relationships/api/add-relationships-from-api.ts index 20ac8c3a681..13e9e7d0b92 100644 --- a/frontend/app/src/entities/nodes/relationships/api/add-relationships-from-api.ts +++ b/frontend/app/src/entities/nodes/relationships/api/add-relationships-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; const ADD_RELATIONSHIP = graphql(` diff --git a/frontend/app/src/entities/nodes/relationships/api/get-default-parent-from-api.ts b/frontend/app/src/entities/nodes/relationships/api/get-default-parent-from-api.ts index 434f0824cf3..4f269cd266b 100644 --- a/frontend/app/src/entities/nodes/relationships/api/get-default-parent-from-api.ts +++ b/frontend/app/src/entities/nodes/relationships/api/get-default-parent-from-api.ts @@ -1,8 +1,7 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery, VariableType } from "json-to-graphql-query"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { nodeCoreFragment } from "@/shared/api/graphql/fragments"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; import type { ContextParams } from "@/shared/api/types"; import type { FormRelationshipValue } from "@/shared/components/form/type"; @@ -74,7 +73,7 @@ export const getDefaultParentFromApi = ({ return { data: null, error: null }; } - const query = gql( + const query = graphql( getRelationshipParent({ kind: parentRelationship?.peer, attribute: `${parentRelationshipAttribute?.name}__ids`, diff --git a/frontend/app/src/entities/nodes/relationships/api/get-object-relationships-from-api.ts b/frontend/app/src/entities/nodes/relationships/api/get-object-relationships-from-api.ts index a9eeb62a4b2..314ce17be38 100644 --- a/frontend/app/src/entities/nodes/relationships/api/get-object-relationships-from-api.ts +++ b/frontend/app/src/entities/nodes/relationships/api/get-object-relationships-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { addAttributesToRequest, addFiltersToRequest, @@ -84,7 +83,7 @@ export const getObjectRelationshipsFromApi = ({ atDate, ...params }: GetObjectRelationshipsFromApiParams) => { - const query = gql(generateObjectRelationshipsQuery(params)); + const query = graphql(generateObjectRelationshipsQuery(params)); return graphqlClient.query({ query, diff --git a/frontend/app/src/entities/nodes/relationships/api/get-relationship-count-from-api.ts b/frontend/app/src/entities/nodes/relationships/api/get-relationship-count-from-api.ts index f8faa995880..c63c46630d9 100644 --- a/frontend/app/src/entities/nodes/relationships/api/get-relationship-count-from-api.ts +++ b/frontend/app/src/entities/nodes/relationships/api/get-relationship-count-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; export type getRelationshipCountQueryParams = { @@ -35,7 +34,7 @@ const getRelationshipCountQuery = ({ }, }; - return gql(jsonToGraphQLQuery(query)); + return graphql(jsonToGraphQLQuery(query)); }; export interface GetRelationshipCountFromApiParams diff --git a/frontend/app/src/entities/nodes/relationships/api/get-relationship-properties-from-api.ts b/frontend/app/src/entities/nodes/relationships/api/get-relationship-properties-from-api.ts index 20c620c5f7c..d9cc9190939 100644 --- a/frontend/app/src/entities/nodes/relationships/api/get-relationship-properties-from-api.ts +++ b/frontend/app/src/entities/nodes/relationships/api/get-relationship-properties-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; type GenerateObjectRelationshipsQueryParams = { parentKind: string; @@ -65,7 +64,7 @@ export const getRelationshipPropertiesFromApi = ({ atDate, ...params }: GetObjectRelationshipsFromApiParams) => { - const query = gql(generateRelationshipPropertiesQuery(params)); + const query = graphql(generateRelationshipPropertiesQuery(params)); return graphqlClient.query({ query, diff --git a/frontend/app/src/entities/nodes/relationships/api/get-relationships-from-api.ts b/frontend/app/src/entities/nodes/relationships/api/get-relationships-from-api.ts index 2617467d899..98d3da6af16 100644 --- a/frontend/app/src/entities/nodes/relationships/api/get-relationships-from-api.ts +++ b/frontend/app/src/entities/nodes/relationships/api/get-relationships-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams, PaginationParams } from "@/shared/api/types"; type GenerateRelationshipListQueryParams = PaginationParams & { @@ -60,7 +59,9 @@ export const getRelationshipsFromApi = async ({ atDate, filterQuery, }: getRelationshipsFromApiParams) => { - const query = gql(generateRelationshipListQuery({ peer, limit, offset, search, filterQuery })); + const query = graphql( + generateRelationshipListQuery({ peer, limit, offset, search, filterQuery }) + ); return graphqlClient.query({ query, diff --git a/frontend/app/src/entities/nodes/relationships/api/remove-relationships-from-api.ts b/frontend/app/src/entities/nodes/relationships/api/remove-relationships-from-api.ts index 14928b8a07c..41cc8e0de6e 100644 --- a/frontend/app/src/entities/nodes/relationships/api/remove-relationships-from-api.ts +++ b/frontend/app/src/entities/nodes/relationships/api/remove-relationships-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; const REMOVE_RELATIONSHIP = graphql(` diff --git a/frontend/app/src/entities/path-traversal/api/get-path-traversal-from-api.ts b/frontend/app/src/entities/path-traversal/api/get-path-traversal-from-api.ts index f92cf32374b..727a1c39583 100644 --- a/frontend/app/src/entities/path-traversal/api/get-path-traversal-from-api.ts +++ b/frontend/app/src/entities/path-traversal/api/get-path-traversal-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { GetPathTraversalParams, @@ -70,7 +69,7 @@ export async function getPathTraversalFromApi(params: GetPathTraversalParams) { }); return graphqlClient.query<{ InfrahubPathTraversal: PathTraversalResponse }>({ - query: gql(queryString), + query: graphql(queryString), context: { branch: branchName, date: atDate }, }); } diff --git a/frontend/app/src/entities/path-traversal/api/get-reachable-nodes-from-api.ts b/frontend/app/src/entities/path-traversal/api/get-reachable-nodes-from-api.ts index 82a4f18b000..5a4b8fb8fb0 100644 --- a/frontend/app/src/entities/path-traversal/api/get-reachable-nodes-from-api.ts +++ b/frontend/app/src/entities/path-traversal/api/get-reachable-nodes-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { GetReachableNodesParams, @@ -68,7 +67,7 @@ export async function getReachableNodesFromApi(params: GetReachableNodesParams) }); return graphqlClient.query<{ InfrahubReachableNodes: ReachableNodesResponse }>({ - query: gql(queryString), + query: graphql(queryString), context: { branch: branchName, date: atDate }, }); } diff --git a/frontend/app/src/entities/path-traversal/domain/model/path-traversal.ts b/frontend/app/src/entities/path-traversal/domain/model/path-traversal.ts index c0509e786cf..7d61b81e5e6 100644 --- a/frontend/app/src/entities/path-traversal/domain/model/path-traversal.ts +++ b/frontend/app/src/entities/path-traversal/domain/model/path-traversal.ts @@ -51,7 +51,7 @@ export type ReachableNodesResponse = { type ContextParams = { branchName?: string; - atDate?: Date | string | null; + atDate?: Date | null; }; export type GetPathTraversalParams = ContextParams & { diff --git a/frontend/app/src/entities/permission/api/get-global-permissions-from-api.ts b/frontend/app/src/entities/permission/api/get-global-permissions-from-api.ts index 7df9568fdd7..4850e6e8195 100644 --- a/frontend/app/src/entities/permission/api/get-global-permissions-from-api.ts +++ b/frontend/app/src/entities/permission/api/get-global-permissions-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const GET_GLOBAL_PERMISSIONS = graphql(` query InfrahubGlobalPermissions { diff --git a/frontend/app/src/entities/permission/api/get-permissions-from-api.ts b/frontend/app/src/entities/permission/api/get-permissions-from-api.ts index ea510b44e34..710ff62ef23 100644 --- a/frontend/app/src/entities/permission/api/get-permissions-from-api.ts +++ b/frontend/app/src/entities/permission/api/get-permissions-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; export type GetPermissionsFromApiParams = ContextParams & { kind: string }; @@ -34,7 +33,7 @@ export const getPermissionsFromApi = ({ branchName, atDate, }: GetPermissionsFromApiParams) => { - const query = gql(getObjectPermissionsQuery(kind)); + const query = graphql(getObjectPermissionsQuery(kind)); return graphqlClient.query({ query, context: { diff --git a/frontend/app/src/entities/preferences/api/get-effective-preferences-from-api.ts b/frontend/app/src/entities/preferences/api/get-effective-preferences-from-api.ts index bd6bab5d609..1ee198e6e7d 100644 --- a/frontend/app/src/entities/preferences/api/get-effective-preferences-from-api.ts +++ b/frontend/app/src/entities/preferences/api/get-effective-preferences-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; // Preferences resolved user → global → default; each field carries its resolved value and source. const GET_EFFECTIVE_PREFERENCES = graphql(` diff --git a/frontend/app/src/entities/preferences/api/get-global-preferences-from-api.ts b/frontend/app/src/entities/preferences/api/get-global-preferences-from-api.ts index b22007b6dab..6fa68f2e63d 100644 --- a/frontend/app/src/entities/preferences/api/get-global-preferences-from-api.ts +++ b/frontend/app/src/entities/preferences/api/get-global-preferences-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const GET_GLOBAL_PREFERENCES = graphql(` query InfrahubGlobalPreferences { diff --git a/frontend/app/src/entities/preferences/api/update-global-preference-from-api.ts b/frontend/app/src/entities/preferences/api/update-global-preference-from-api.ts index 5ca65061159..c21598859d0 100644 --- a/frontend/app/src/entities/preferences/api/update-global-preference-from-api.ts +++ b/frontend/app/src/entities/preferences/api/update-global-preference-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const UPDATE_GLOBAL_PREFERENCE = graphql(` mutation UpdateGlobalPreference($dateFormat: DateFormat, $timezone: String) { diff --git a/frontend/app/src/entities/preferences/api/upsert-user-preferences-from-api.ts b/frontend/app/src/entities/preferences/api/upsert-user-preferences-from-api.ts index 1d6a180513a..a50d5d7d3f5 100644 --- a/frontend/app/src/entities/preferences/api/upsert-user-preferences-from-api.ts +++ b/frontend/app/src/entities/preferences/api/upsert-user-preferences-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const UPSERT_USER_PREFERENCE = graphql(` mutation UpsertUserPreference($dateFormat: DateFormat, $timezone: String) { diff --git a/frontend/app/src/entities/proposed-changes/api/create-proposed-change-from-api.ts b/frontend/app/src/entities/proposed-changes/api/create-proposed-change-from-api.ts index 2141fba59c8..7ac778ef82d 100644 --- a/frontend/app/src/entities/proposed-changes/api/create-proposed-change-from-api.ts +++ b/frontend/app/src/entities/proposed-changes/api/create-proposed-change-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; export const CREATE_PROPOSED_CHANGE = graphql(` mutation CoreProposedChangeCreate( diff --git a/frontend/app/src/entities/proposed-changes/api/get-proposed-change-details-from-api.ts b/frontend/app/src/entities/proposed-changes/api/get-proposed-change-details-from-api.ts index 9cfb42004df..d2f296cb91e 100644 --- a/frontend/app/src/entities/proposed-changes/api/get-proposed-change-details-from-api.ts +++ b/frontend/app/src/entities/proposed-changes/api/get-proposed-change-details-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const GET_PROPOSED_CHANGE_DETAILS = graphql(` query GET_PROPOSED_CHANGE_DETAILS($proposedChangeId: ID) { diff --git a/frontend/app/src/entities/proposed-changes/api/get-proposed-change-thread-from-api.ts b/frontend/app/src/entities/proposed-changes/api/get-proposed-change-thread-from-api.ts index 2a2fd26431e..36eebde12e7 100644 --- a/frontend/app/src/entities/proposed-changes/api/get-proposed-change-thread-from-api.ts +++ b/frontend/app/src/entities/proposed-changes/api/get-proposed-change-thread-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const GET_THREAD = graphql(` query GetCoreThread($ids: [ID]) { diff --git a/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-available-actions-from-api.ts b/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-available-actions-from-api.ts index 00d0b8ea12c..3f7e53707c6 100644 --- a/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-available-actions-from-api.ts +++ b/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-available-actions-from-api.ts @@ -1,7 +1,10 @@ -import { graphql, type ResultOf, type VariablesOf } from "gql.tada"; - +import { + graphql, + graphqlClient, + type ResultOf, + type VariablesOf, +} from "@/shared/api/graphql/client"; import type { ActionAvailability } from "@/shared/api/graphql/generated/types"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; const QUERY = graphql(` query actions($proposedChangeId: String!) { diff --git a/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-counts-from-api.ts b/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-counts-from-api.ts index 97681c0fe88..113478d3585 100644 --- a/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-counts-from-api.ts +++ b/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-counts-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { addFiltersToRequest } from "@/shared/api/graphql/utils"; import type { Filter } from "@/entities/nodes/filters/domain/model/filter"; @@ -15,7 +14,7 @@ export interface ProposedChangesCountsFromApiParams { export const getProposedChangesCountsFromApi = async ({ filters, }: ProposedChangesCountsFromApiParams) => { - const query = gql( + const query = graphql( jsonToGraphQLQuery({ query: { __name: "GET_PROPOSED_CHANGE_COUNTS", diff --git a/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-from-api.ts b/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-from-api.ts index ebb9e3b2f36..7dcec15e71b 100644 --- a/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-from-api.ts +++ b/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { EnumType, jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { addAttributesToRequest, addFiltersToRequest, @@ -86,7 +85,7 @@ export const getProposedChangesFromApi = async ({ }, }); - const query = gql(queryString); + const query = graphql(queryString); return graphqlClient.query({ query, }); diff --git a/frontend/app/src/entities/proposed-changes/api/update-proposed-change-review-from-api.ts b/frontend/app/src/entities/proposed-changes/api/update-proposed-change-review-from-api.ts index 4e77780064a..da0deed2a63 100644 --- a/frontend/app/src/entities/proposed-changes/api/update-proposed-change-review-from-api.ts +++ b/frontend/app/src/entities/proposed-changes/api/update-proposed-change-review-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const MUTATION = graphql(` mutation ProposedChangeReview($proposedChangeId: String!, $decision: ProposedChangeApprovalDecision!) { diff --git a/frontend/app/src/entities/repository/api/check-connectivity-from-api.ts b/frontend/app/src/entities/repository/api/check-connectivity-from-api.ts index 431ddbc0bae..9a3494bcd76 100644 --- a/frontend/app/src/entities/repository/api/check-connectivity-from-api.ts +++ b/frontend/app/src/entities/repository/api/check-connectivity-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const CHECK_REPOSITORY_CONNECTIVITY = graphql(` mutation CHECK_REPOSITORY_CONNECTIVITY($repositoryId: String!) { diff --git a/frontend/app/src/entities/repository/api/get-repository-group-from-api.ts b/frontend/app/src/entities/repository/api/get-repository-group-from-api.ts index 28aa9d93898..420abdfa948 100644 --- a/frontend/app/src/entities/repository/api/get-repository-group-from-api.ts +++ b/frontend/app/src/entities/repository/api/get-repository-group-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; const REPOSITORY_GROUP = graphql(` diff --git a/frontend/app/src/entities/repository/api/import-current-commit-from-api.ts b/frontend/app/src/entities/repository/api/import-current-commit-from-api.ts index 7c5b4772f08..ff0ebeff201 100644 --- a/frontend/app/src/entities/repository/api/import-current-commit-from-api.ts +++ b/frontend/app/src/entities/repository/api/import-current-commit-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; const IMPORT_CURRENT_COMMIT = graphql(` diff --git a/frontend/app/src/entities/repository/api/reimport-last-commit-from-api.ts b/frontend/app/src/entities/repository/api/reimport-last-commit-from-api.ts index 4ceec003ba0..0af40b254c6 100644 --- a/frontend/app/src/entities/repository/api/reimport-last-commit-from-api.ts +++ b/frontend/app/src/entities/repository/api/reimport-last-commit-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; const REIMPORT_LAST_COMMIT = graphql(` diff --git a/frontend/app/src/entities/resource-manager/api/allocate-resource-from-api.ts b/frontend/app/src/entities/resource-manager/api/allocate-resource-from-api.ts index 52b484ff932..10d01e239de 100644 --- a/frontend/app/src/entities/resource-manager/api/allocate-resource-from-api.ts +++ b/frontend/app/src/entities/resource-manager/api/allocate-resource-from-api.ts @@ -1,7 +1,6 @@ -import { gql } from "@apollo/client"; import { jsonToGraphQLQuery } from "json-to-graphql-query"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import type { BranchContextParams } from "@/shared/api/types"; export interface AllocateResourceFromApiParams extends BranchContextParams { @@ -35,7 +34,7 @@ export function allocateResourceFromApi({ }); return graphqlClient.mutate({ - mutation: gql(mutation), + mutation: graphql(mutation), context: { branch: branchName, }, diff --git a/frontend/app/src/entities/resource-manager/api/get-number-pools-from-api.ts b/frontend/app/src/entities/resource-manager/api/get-number-pools-from-api.ts index ca2f4eafd01..c75e140fae9 100644 --- a/frontend/app/src/entities/resource-manager/api/get-number-pools-from-api.ts +++ b/frontend/app/src/entities/resource-manager/api/get-number-pools-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; import type { ContextParams } from "@/shared/api/types"; const GET_NUMBER_POOLS = graphql(` diff --git a/frontend/app/src/entities/resource-manager/api/get-pool-utilization-from-api.ts b/frontend/app/src/entities/resource-manager/api/get-pool-utilization-from-api.ts index e508b500f55..afa834312ca 100644 --- a/frontend/app/src/entities/resource-manager/api/get-pool-utilization-from-api.ts +++ b/frontend/app/src/entities/resource-manager/api/get-pool-utilization-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_POOL_UTILIZATION = graphql(` query GET_POOL_UTILIZATION($poolId: String!) { diff --git a/frontend/app/src/entities/resource-manager/api/get-resource-allocated-from-api.ts b/frontend/app/src/entities/resource-manager/api/get-resource-allocated-from-api.ts index a844915e62e..97dc2dd37cd 100644 --- a/frontend/app/src/entities/resource-manager/api/get-resource-allocated-from-api.ts +++ b/frontend/app/src/entities/resource-manager/api/get-resource-allocated-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_RESOURCE_ALLOCATED = graphql(` query GET_RESOURCE_POOL_ALLOCATED( diff --git a/frontend/app/src/entities/schema/api/add-dropdown-from-api.ts b/frontend/app/src/entities/schema/api/add-dropdown-from-api.ts index ed40363901d..5b397889f49 100644 --- a/frontend/app/src/entities/schema/api/add-dropdown-from-api.ts +++ b/frontend/app/src/entities/schema/api/add-dropdown-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; export const DROPDOWN_ADD_MUTATION = graphql(` mutation DropdownAdd( diff --git a/frontend/app/src/entities/schema/api/add-enum-from-api.ts b/frontend/app/src/entities/schema/api/add-enum-from-api.ts index c0136b22301..bafe9f9a22f 100644 --- a/frontend/app/src/entities/schema/api/add-enum-from-api.ts +++ b/frontend/app/src/entities/schema/api/add-enum-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; export const ENUM_ADD_MUTATION = graphql(` mutation EnumAdd($kind: String!, $attribute: String!, $enum: String!) { diff --git a/frontend/app/src/entities/schema/api/remove-dropdown-from-api.ts b/frontend/app/src/entities/schema/api/remove-dropdown-from-api.ts index 478502bfbb4..6f234776c97 100644 --- a/frontend/app/src/entities/schema/api/remove-dropdown-from-api.ts +++ b/frontend/app/src/entities/schema/api/remove-dropdown-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; export const DROPDOWN_REMOVE_MUTATION = graphql(` mutation DropdownDelete($kind: String!, $attribute: String!, $dropdown: String!) { diff --git a/frontend/app/src/entities/schema/api/remove-enum-from-api.ts b/frontend/app/src/entities/schema/api/remove-enum-from-api.ts index 44a57911e1e..5b57bd243d2 100644 --- a/frontend/app/src/entities/schema/api/remove-enum-from-api.ts +++ b/frontend/app/src/entities/schema/api/remove-enum-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; export const ENUM_REMOVE_MUTATION = graphql(` mutation EnumDelete($kind: String!, $attribute: String!, $enum: String!) { diff --git a/frontend/app/src/entities/tasks/api/cancel-task-from-api.ts b/frontend/app/src/entities/tasks/api/cancel-task-from-api.ts index 16de559f811..61f59e32b38 100644 --- a/frontend/app/src/entities/tasks/api/cancel-task-from-api.ts +++ b/frontend/app/src/entities/tasks/api/cancel-task-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const CANCEL_TASK = graphql(` mutation CANCEL_TASK($id: String!) { diff --git a/frontend/app/src/entities/tasks/api/check-task-details-from-api.ts b/frontend/app/src/entities/tasks/api/check-task-details-from-api.ts index 1a0001517d3..76da46fd17b 100644 --- a/frontend/app/src/entities/tasks/api/check-task-details-from-api.ts +++ b/frontend/app/src/entities/tasks/api/check-task-details-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const TASK_DETAILS_CHECK = graphql(` query TASK_DETAILS_CHECK( diff --git a/frontend/app/src/entities/tasks/api/get-branch-task-status-from-api.ts b/frontend/app/src/entities/tasks/api/get-branch-task-status-from-api.ts index 7a5461815f2..89d75662d92 100644 --- a/frontend/app/src/entities/tasks/api/get-branch-task-status-from-api.ts +++ b/frontend/app/src/entities/tasks/api/get-branch-task-status-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const TASKS_BRANCH_STATUS_COUNT = graphql(` query TASKS_BRANCH_STATUS_COUNT($branch: String!) { diff --git a/frontend/app/src/entities/tasks/api/get-task-count-from-api.ts b/frontend/app/src/entities/tasks/api/get-task-count-from-api.ts index 4fb79761e56..6f5fe39e18a 100644 --- a/frontend/app/src/entities/tasks/api/get-task-count-from-api.ts +++ b/frontend/app/src/entities/tasks/api/get-task-count-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const TASK_COUNT = graphql(` query TASK_COUNT( diff --git a/frontend/app/src/entities/tasks/api/get-task-details-from-api.ts b/frontend/app/src/entities/tasks/api/get-task-details-from-api.ts index e9ae71d58cb..90f4aee2192 100644 --- a/frontend/app/src/entities/tasks/api/get-task-details-from-api.ts +++ b/frontend/app/src/entities/tasks/api/get-task-details-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_TASK_DETAILS = graphql(` query GET_TASK_DETAILS( diff --git a/frontend/app/src/entities/tasks/api/get-task-details-title-from-api.ts b/frontend/app/src/entities/tasks/api/get-task-details-title-from-api.ts index 5a1c098fec5..32e2358dafc 100644 --- a/frontend/app/src/entities/tasks/api/get-task-details-title-from-api.ts +++ b/frontend/app/src/entities/tasks/api/get-task-details-title-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_TASK_DETAILS_TITLE = graphql(` query GET_TASK_DETAILS_TITLE_QUERY($ids: [String!]) { diff --git a/frontend/app/src/entities/tasks/api/get-task-list-from-api.ts b/frontend/app/src/entities/tasks/api/get-task-list-from-api.ts index 00b36a4b411..b8bb3e5530d 100644 --- a/frontend/app/src/entities/tasks/api/get-task-list-from-api.ts +++ b/frontend/app/src/entities/tasks/api/get-task-list-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; export const GET_TASK_LIST = graphql(` query GET_TASK_LIST( diff --git a/frontend/app/src/entities/tasks/api/get-tasks-homepage-from-api.ts b/frontend/app/src/entities/tasks/api/get-tasks-homepage-from-api.ts index 77d49423bc1..480d0d96b86 100644 --- a/frontend/app/src/entities/tasks/api/get-tasks-homepage-from-api.ts +++ b/frontend/app/src/entities/tasks/api/get-tasks-homepage-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const GET_TASKS_HOMEPAGE = graphql(` query GET_TASKS_HOMEPAGE($limit: Int, $branchName: String!, $states: [StateType]) { diff --git a/frontend/app/src/entities/tasks/api/retry-task-from-api.ts b/frontend/app/src/entities/tasks/api/retry-task-from-api.ts index 365a0ff35b3..f7b3a17b881 100644 --- a/frontend/app/src/entities/tasks/api/retry-task-from-api.ts +++ b/frontend/app/src/entities/tasks/api/retry-task-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const RETRY_TASK = graphql(` mutation RETRY_TASK($id: String!) { diff --git a/frontend/app/src/entities/user-profile/api/create-account-token-from-api.ts b/frontend/app/src/entities/user-profile/api/create-account-token-from-api.ts index bda73250f79..fddb2e72e15 100644 --- a/frontend/app/src/entities/user-profile/api/create-account-token-from-api.ts +++ b/frontend/app/src/entities/user-profile/api/create-account-token-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const CREATE_ACCOUNT_TOKEN = graphql(` mutation InfrahubAccountTokenCreate($tokenName: String!, $tokenExpirationDate: String) { diff --git a/frontend/app/src/entities/user-profile/api/get-account-profile-from-api.ts b/frontend/app/src/entities/user-profile/api/get-account-profile-from-api.ts index 339549ff3c1..c9d62a18c54 100644 --- a/frontend/app/src/entities/user-profile/api/get-account-profile-from-api.ts +++ b/frontend/app/src/entities/user-profile/api/get-account-profile-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const GET_ACCOUNT_PROFILE = graphql(` query GetAccountProfile { diff --git a/frontend/app/src/entities/user-profile/api/get-account-token-from-api.ts b/frontend/app/src/entities/user-profile/api/get-account-token-from-api.ts index 6b785e70007..6a7e2db85f0 100644 --- a/frontend/app/src/entities/user-profile/api/get-account-token-from-api.ts +++ b/frontend/app/src/entities/user-profile/api/get-account-token-from-api.ts @@ -1,6 +1,4 @@ -import { graphql } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient } from "@/shared/api/graphql/client"; const query = graphql(` query InfrahubAccountToken { diff --git a/frontend/app/src/entities/user-profile/api/update-account-password-from-api.ts b/frontend/app/src/entities/user-profile/api/update-account-password-from-api.ts index 310e7db6efa..6fcfb12b05f 100644 --- a/frontend/app/src/entities/user-profile/api/update-account-password-from-api.ts +++ b/frontend/app/src/entities/user-profile/api/update-account-password-from-api.ts @@ -1,6 +1,4 @@ -import { graphql, type VariablesOf } from "gql.tada"; - -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; +import { graphql, graphqlClient, type VariablesOf } from "@/shared/api/graphql/client"; const UPDATE_ACCOUNT_PASSWORD = graphql(` mutation UPDATE_ACCOUNT_PASSWORD($password: String!) { diff --git a/frontend/app/src/shared/api/graphql/client.dedup.test.ts b/frontend/app/src/shared/api/graphql/client.dedup.test.ts new file mode 100644 index 00000000000..46fe1f83781 --- /dev/null +++ b/frontend/app/src/shared/api/graphql/client.dedup.test.ts @@ -0,0 +1,77 @@ +import { gql } from "@urql/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { graphqlClient } from "./client"; + +// Guards against urql merging concurrent identical operations that target different endpoints. +describe("concurrent identical query across endpoints", () => { + let fetchSpy: ReturnType; + + const SAME_QUERY = gql` + query SameQuery { + __typename + } + `; + + beforeEach(() => { + // Echo the endpoint back so each caller can be matched to the URL it targeted. + fetchSpy = vi.fn((url: string) => { + const [branch, at] = url.split("/graphql/")[1]?.split("?at=") ?? ["?"]; + return Promise.resolve( + new Response(JSON.stringify({ data: { __typename: at ? `${branch}@${at}` : branch } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + }); + vi.stubGlobal("fetch", fetchSpy); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("fires a distinct request per branch for concurrent identical query+variables", async () => { + // WHEN + const [a, b] = await Promise.all([ + graphqlClient.query({ query: SAME_QUERY, context: { branch: "branch-a" } }), + graphqlClient.query({ query: SAME_QUERY, context: { branch: "branch-b" } }), + ]); + + // THEN + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(a.data).toEqual({ __typename: "branch-a" }); + expect(b.data).toEqual({ __typename: "branch-b" }); + }); + + it("fires a distinct request per point-in-time on the same branch", async () => { + // GIVEN + const early = new Date("2026-01-01T00:00:00.000Z"); + const late = new Date("2026-06-01T00:00:00.000Z"); + + // WHEN + const [a, b] = await Promise.all([ + graphqlClient.query({ query: SAME_QUERY, context: { branch: "main", date: early } }), + graphqlClient.query({ query: SAME_QUERY, context: { branch: "main", date: late } }), + ]); + + // THEN + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(a.data).toEqual({ __typename: `main@${early.toISOString()}` }); + expect(b.data).toEqual({ __typename: `main@${late.toISOString()}` }); + }); + + it("still merges two identical concurrent queries on the same endpoint", async () => { + // WHEN + const [a, b] = await Promise.all([ + graphqlClient.query({ query: SAME_QUERY, context: { branch: "branch-a" } }), + graphqlClient.query({ query: SAME_QUERY, context: { branch: "branch-a" } }), + ]); + + // THEN + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(a.data).toEqual({ __typename: "branch-a" }); + expect(b.data).toEqual({ __typename: "branch-a" }); + }); +}); diff --git a/frontend/app/src/shared/api/graphql/client.test.ts b/frontend/app/src/shared/api/graphql/client.test.ts new file mode 100644 index 00000000000..d9425056598 --- /dev/null +++ b/frontend/app/src/shared/api/graphql/client.test.ts @@ -0,0 +1,379 @@ +import { CombinedError, gql } from "@urql/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ERROR_CODES } from "@/shared/api/errors"; +import { PRIORITY_HEADER } from "@/shared/api/priority"; +import { queryClient } from "@/shared/api/rest/client"; +import { CONFIG } from "@/shared/config/config"; + +import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from "@/entities/authentication/api/token-storage"; +import { __navigation } from "@/entities/authentication/domain/use-cases/redirect-to-login"; + +import { graphqlClient } from "./client"; +import { handleGraphQLErrors } from "./error-handling"; + +function combinedError(code: string, message = "boom") { + return new CombinedError({ + graphQLErrors: [{ message, extensions: { code, http_status: 401, data: {} } }], + }); +} + +describe("graphqlClient — endpoint targeting", () => { + let fetchSpy: ReturnType; + + const PING = gql` + query Ping { + __typename + } + `; + + beforeEach(() => { + fetchSpy = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify({ data: { __typename: "Query" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + ); + vi.stubGlobal("fetch", fetchSpy); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("targets the default branch when the operation declares no context", async () => { + // WHEN + await graphqlClient.query({ query: PING }); + + // THEN + expect(fetchSpy.mock.calls[0]?.[0]).toBe(CONFIG.GRAPHQL_URL()); + }); + + it("targets the branch and point in time the operation declares", async () => { + // GIVEN + const date = new Date("2026-01-01T00:00:00.000Z"); + + // WHEN + await graphqlClient.query({ query: PING, context: { branch: "feature", date } }); + + // THEN + expect(fetchSpy.mock.calls[0]?.[0]).toBe(CONFIG.GRAPHQL_URL("feature", date)); + }); + + it("stamps X-Priority: high on every operation", async () => { + // WHEN + await graphqlClient.query({ query: PING }); + + // THEN + const init = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect(new Headers(init.headers).get(PRIORITY_HEADER)).toBe("high"); + }); +}); + +describe("handleGraphQLErrors — catalogue routing", () => { + let assignSpy: ReturnType; + let originalAssign: typeof __navigation.assign; + + beforeEach(() => { + originalAssign = __navigation.assign; + assignSpy = vi.fn(); + __navigation.assign = assignSpy as unknown as typeof __navigation.assign; + }); + + afterEach(() => { + __navigation.assign = originalAssign; + localStorage.clear(); + }); + + it("redirects to /login on a persistent TOKEN_EXPIRED", () => { + // WHEN + handleGraphQLErrors(combinedError(ERROR_CODES.TOKEN_EXPIRED)); + + // THEN + expect(assignSpy).toHaveBeenCalledOnce(); + }); + + it("redirects to /login on AUTHENTICATION_REQUIRED", () => { + // WHEN + handleGraphQLErrors(combinedError(ERROR_CODES.AUTHENTICATION_REQUIRED)); + + // THEN + expect(assignSpy).toHaveBeenCalledOnce(); + }); + + it("stays silent on PERMISSION_DENIED (no redirect, no override call)", () => { + // GIVEN + const processErrorMessage = vi.fn(); + + // WHEN + handleGraphQLErrors(combinedError(ERROR_CODES.PERMISSION_DENIED), { processErrorMessage }); + + // THEN + expect(assignSpy).not.toHaveBeenCalled(); + expect(processErrorMessage).not.toHaveBeenCalled(); + }); + + it("routes a generic error through the caller's processErrorMessage override", () => { + // GIVEN + const processErrorMessage = vi.fn(); + + // WHEN + handleGraphQLErrors(combinedError(ERROR_CODES.UNDEFINED_ERROR, "nope"), { + processErrorMessage, + }); + + // THEN + expect(processErrorMessage).toHaveBeenCalledWith("nope"); + expect(assignSpy).not.toHaveBeenCalled(); + }); + + it("still redirects when an unrouted error precedes AUTHENTICATION_REQUIRED", () => { + // GIVEN + const processErrorMessage = vi.fn(); + const error = new CombinedError({ + graphQLErrors: [ + { + message: "boom", + extensions: { code: ERROR_CODES.NODE_NOT_FOUND, http_status: 404, data: {} }, + }, + { message: "gap", extensions: { code: "NOT_IN_CATALOGUE", http_status: 500, data: {} } }, + { + message: "auth", + extensions: { code: ERROR_CODES.AUTHENTICATION_REQUIRED, http_status: 401, data: {} }, + }, + ], + }); + + // WHEN + handleGraphQLErrors(error, { processErrorMessage }); + + // THEN + expect(processErrorMessage).toHaveBeenCalledWith("boom"); + expect(assignSpy).toHaveBeenCalledOnce(); + }); + + it("does nothing when there is no error", () => { + // GIVEN + const processErrorMessage = vi.fn(); + + // WHEN + handleGraphQLErrors(undefined, { processErrorMessage }); + + // THEN + expect(processErrorMessage).not.toHaveBeenCalled(); + expect(assignSpy).not.toHaveBeenCalled(); + }); +}); + +describe("graphqlClient — token refresh integration", () => { + let assignSpy: ReturnType; + let originalAssign: typeof __navigation.assign; + let fetchQuerySpy: ReturnType; + let fetchSpy: ReturnType; + + const PING = gql` + query Ping { + __typename + } + `; + + const PING_MUTATION = gql` + mutation Ping { + __typename + } + `; + + function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + const tokenExpiredBody = { + data: null, + errors: [ + { + message: "Token expired", + extensions: { code: ERROR_CODES.TOKEN_EXPIRED, http_status: 401, data: {} }, + }, + ], + }; + + beforeEach(() => { + localStorage.setItem(ACCESS_TOKEN_KEY, "old-token"); + localStorage.setItem(REFRESH_TOKEN_KEY, "old-refresh"); + originalAssign = __navigation.assign; + assignSpy = vi.fn(); + __navigation.assign = assignSpy as unknown as typeof __navigation.assign; + fetchQuerySpy = vi.spyOn(queryClient, "fetchQuery"); + fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + }); + + afterEach(() => { + __navigation.assign = originalAssign; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + localStorage.clear(); + }); + + it("sends queries as POST (backend /graphql rejects GET → SPA fallback HTML)", async () => { + // GIVEN + fetchSpy.mockResolvedValueOnce(jsonResponse({ data: { __typename: "Query" } })); + + // WHEN + await graphqlClient.query({ query: PING }); + + // THEN + const init = fetchSpy.mock.calls[0]?.[1] as RequestInit | undefined; + expect(init?.method).toBe("POST"); + }); + + it("injects __typename into selections (Apollo InMemoryCache parity)", async () => { + // GIVEN + fetchSpy.mockResolvedValueOnce(jsonResponse({ data: {} })); + + // WHEN + await graphqlClient.query({ + query: gql` + { + InfraDevice { + edges { + node { + id + } + } + } + } + `, + }); + + // THEN + const body = JSON.parse((fetchSpy.mock.calls[0]?.[1] as RequestInit).body as string); + expect(body.query).toContain("__typename"); + }); + + it("attaches the bearer token when an access token is present", async () => { + // GIVEN + fetchSpy.mockResolvedValueOnce(jsonResponse({ data: {} })); + + // WHEN + await graphqlClient.query({ query: PING }); + + // THEN + const init = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect(new Headers(init.headers).get("authorization")).toBe("Bearer old-token"); + }); + + it("omits the bearer token when no access token is present", async () => { + // GIVEN + localStorage.removeItem(ACCESS_TOKEN_KEY); + fetchSpy.mockResolvedValueOnce(jsonResponse({ data: {} })); + + // WHEN + await graphqlClient.query({ query: PING }); + + // THEN + const init = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect(new Headers(init.headers).get("authorization")).toBeNull(); + }); + + it("bails to /login when the refresh throws", async () => { + // GIVEN + fetchSpy.mockImplementation(() => Promise.resolve(jsonResponse(tokenExpiredBody))); + fetchQuerySpy.mockRejectedValue(new Error("refresh failed")); + + // WHEN + await graphqlClient.query({ query: PING }).catch(() => {}); + + // THEN + expect(assignSpy).toHaveBeenCalled(); + }); + + it("refreshes once and replays successfully on TOKEN_EXPIRED", async () => { + // GIVEN + fetchSpy + .mockResolvedValueOnce(jsonResponse(tokenExpiredBody)) + .mockResolvedValueOnce(jsonResponse({ data: { __typename: "Query" } })); + fetchQuerySpy.mockResolvedValue({ access_token: "new-token", refresh_token: "new-refresh" }); + + // WHEN + const result = await graphqlClient.query({ query: PING }); + + // THEN + expect(result.data).toEqual({ __typename: "Query" }); + expect(result.errors).toBeUndefined(); + expect(fetchQuerySpy).toHaveBeenCalledOnce(); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(assignSpy).not.toHaveBeenCalled(); + const replay = new Headers((fetchSpy.mock.calls[1]?.[1] as RequestInit).headers); + expect(replay.get(PRIORITY_HEADER)).toBe("high"); + }); + + it("bails to /login when TOKEN_EXPIRED persists after the single replay", async () => { + // GIVEN + fetchSpy.mockImplementation(() => Promise.resolve(jsonResponse(tokenExpiredBody))); + fetchQuerySpy.mockResolvedValue({ access_token: "new-token", refresh_token: "new-refresh" }); + + // WHEN + const querying = graphqlClient.query({ query: PING }); + + // THEN + await expect(querying).rejects.toThrow("Token expired"); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchQuerySpy).toHaveBeenCalledOnce(); + expect(assignSpy).toHaveBeenCalled(); + }); + + it("rejects a query on GraphQL errors even when the response carries data", async () => { + // GIVEN + fetchSpy.mockResolvedValueOnce( + jsonResponse({ + data: { __typename: "Query" }, + errors: [ + { + message: "partial", + extensions: { code: ERROR_CODES.UNDEFINED_ERROR, http_status: 500, data: {} }, + }, + ], + }) + ); + + // WHEN + const querying = graphqlClient.query({ + query: PING, + context: { processErrorMessage: () => {} }, + }); + + // THEN + await expect(querying).rejects.toThrow("partial"); + }); + + it("rejects when a mutation responds with GraphQL errors", async () => { + // GIVEN + fetchSpy.mockResolvedValueOnce( + jsonResponse({ + data: null, + errors: [ + { + message: "Cannot delete Device 'x'.", + extensions: { code: ERROR_CODES.UNDEFINED_ERROR, http_status: 500, data: {} }, + }, + ], + }) + ); + + // WHEN + const mutating = graphqlClient.mutate({ + mutation: PING_MUTATION, + context: { processErrorMessage: () => {} }, + }); + + // THEN + await expect(mutating).rejects.toThrow("Cannot delete Device 'x'."); + }); +}); diff --git a/frontend/app/src/shared/api/graphql/client.ts b/frontend/app/src/shared/api/graphql/client.ts new file mode 100644 index 00000000000..90a1d7eaa34 --- /dev/null +++ b/frontend/app/src/shared/api/graphql/client.ts @@ -0,0 +1,134 @@ +import { + type AnyVariables, + Client, + type CombinedError, + type DocumentInput, + fetchExchange, + formatDocument, + makeOperation, + mapExchange, +} from "@urql/core"; +import { authExchange } from "@urql/exchange-auth"; + +import { ERROR_CODES } from "@/shared/api/errors"; +import { handleGraphQLErrors, hasCatalogueCode } from "@/shared/api/graphql/error-handling"; +import type { GraphQLRequestContext, GraphQLResult } from "@/shared/api/graphql/types"; +import { DEFAULT_PRIORITY, PRIORITY_HEADER } from "@/shared/api/priority"; +import { queryClient } from "@/shared/api/rest/client"; +import { CONFIG } from "@/shared/config/config"; + +import { getAccessToken } from "@/entities/authentication/api/token-storage"; +import { redirectToLogin } from "@/entities/authentication/domain/use-cases/redirect-to-login"; +import { refreshAccessTokenQueryOptions } from "@/entities/authentication/ui/queries/refresh-access-token.query"; + +// biome-ignore lint/performance/noBarrelFile: Re-exported so authoring and running a query need only this module. +export { graphql, type ResultOf, type VariablesOf } from "gql.tada"; + +// Add `__typename` to every selection set +const addTypenameExchange = mapExchange({ + onOperation: (operation) => + makeOperation( + operation.kind, + { ...operation, query: formatDocument(operation.query) }, + operation.context + ), +}); + +const authenticationExchange = authExchange(async (authUtilities) => { + return { + addAuthToOperation: (operation) => { + const accessToken = getAccessToken(); + if (!accessToken) return operation; + + return authUtilities.appendHeaders(operation, { Authorization: `Bearer ${accessToken}` }); + }, + didAuthError: (error) => { + return hasCatalogueCode(error, ERROR_CODES.TOKEN_EXPIRED); + }, + refreshAuth: async () => { + await queryClient.fetchQuery(refreshAccessTokenQueryOptions()).catch((error) => { + redirectToLogin(); + throw error; + }); + }, + }; +}); + +// Grows only with the branch/point-in-time endpoints a session visits, and dies with the page. +const clientsByEndpoint = new Map(); + +// urql's Client dedups concurrent operations by hash(query, variables) and ignores the URL. +// but this app carries the branch/point-in-time in the URL, not in variables. +// Without this, two concurrent identical query+variables on DIFFERENT branches share one network request +// and both receive one branch's data. +function getGraphqlClient(branch?: string | null, date?: Date | null): Client { + const url = CONFIG.GRAPHQL_URL(branch, date); + let client = clientsByEndpoint.get(url); + if (!client) { + client = new Client({ + url, + preferGetMethod: false, + fetchOptions: { + headers: { + [PRIORITY_HEADER]: DEFAULT_PRIORITY, + }, + }, + exchanges: [addTypenameExchange, authenticationExchange, fetchExchange], + }); + clientsByEndpoint.set(url, client); + } + return client; +} + +// Map urql result to the preserved `{ data, errors }` shape and run error routing. +function toGraphQLResult( + data: TData | undefined, + error: CombinedError | undefined, + context?: GraphQLRequestContext +): GraphQLResult { + handleGraphQLErrors(error, context); + + if (error?.networkError) { + throw error.networkError; + } + + if (error?.graphQLErrors?.length) { + throw new Error(error.graphQLErrors.map((e) => e.message).join("; "), { cause: error }); + } + + return { data: data as TData }; +} + +interface QueryArgs { + query: DocumentInput; + variables?: TVars; + context?: GraphQLRequestContext; +} + +interface MutateArgs { + mutation: DocumentInput; + variables?: TVars; + context?: GraphQLRequestContext; +} + +// The transport-only client the app depends on. +// Preserves the Apollo api: `query`/`mutate` returning `Promise<{ data, errors }>`. +export const graphqlClient = { + async query( + args: QueryArgs + ): Promise> { + const result = await getGraphqlClient(args.context?.branch, args.context?.date) + .query(args.query, args.variables as TVars) + .toPromise(); + return toGraphQLResult(result.data, result.error, args.context); + }, + + async mutate( + args: MutateArgs + ): Promise> { + const result = await getGraphqlClient(args.context?.branch, args.context?.date) + .mutation(args.mutation, args.variables as TVars) + .toPromise(); + return toGraphQLResult(result.data, result.error, args.context); + }, +}; diff --git a/frontend/app/src/shared/api/graphql/error-handling.ts b/frontend/app/src/shared/api/graphql/error-handling.ts new file mode 100644 index 00000000000..4390445cfb8 --- /dev/null +++ b/frontend/app/src/shared/api/graphql/error-handling.ts @@ -0,0 +1,75 @@ +import type { CombinedError } from "@urql/core"; +import React from "react"; +import { toast } from "react-toastify"; + +import { ERROR_CODES, parseCatalogueError } from "@/shared/api/errors"; +import { ALERT_TYPES, Alert } from "@/shared/components/ui/alert"; + +import { redirectToLogin } from "@/entities/authentication/domain/use-cases/redirect-to-login"; + +import type { GraphQLRequestContext } from "./types"; + +export function hasCatalogueCode(error: CombinedError | undefined, code: string): boolean { + return ( + error?.graphQLErrors?.some((e) => parseCatalogueError(e.extensions).code === code) ?? false + ); +} + +function notifyUser(message: string | undefined, context?: GraphQLRequestContext): void { + if (!message) return; + + if (context?.processErrorMessage) { + context.processErrorMessage(message); + return; + } + + toast(React.createElement(Alert, { type: ALERT_TYPES.ERROR, message }), { + toastId: "alert-error", + }); +} + +export function handleGraphQLErrors( + error: CombinedError | undefined, + context?: GraphQLRequestContext +): void { + if (!error?.graphQLErrors?.length) return; + + for (const graphQLError of error.graphQLErrors) { + const parsed = parseCatalogueError(graphQLError.extensions); + + console.error( + `[GraphQL error]: Code: ${parsed.code}, Message: ${graphQLError.message}, ` + + `Location: ${JSON.stringify(graphQLError.locations)}, Path: ${graphQLError.path}` + ); + + switch (parsed.code) { + case ERROR_CODES.TOKEN_EXPIRED: + case ERROR_CODES.AUTHENTICATION_REQUIRED: { + redirectToLogin(); + return; + } + + case ERROR_CODES.PERMISSION_DENIED: { + // 403s are handled by route-level guards; `continue` so sibling errors still route. + continue; + } + + case ERROR_CODES.UNDEFINED_ERROR: { + // Unknown catalogue code: surface it loudly in dev so the gap gets registered. + if (import.meta.env.DEV) { + console.error( + "[catalogue gap] Unmatched error code surfaced as UNDEFINED_ERROR. " + + "Register it in backend/infrahub/errors/catalogue.py, regenerate " + + "the schema, and run `pnpm generate:error-bindings`.", + { message: graphQLError.message, extensions: graphQLError.extensions } + ); + } + notifyUser(graphQLError.message, context); + continue; + } + default: { + notifyUser(graphQLError.message, context); + } + } + } +} diff --git a/frontend/app/src/shared/api/graphql/graphqlClientApollo.test.ts b/frontend/app/src/shared/api/graphql/graphqlClientApollo.test.ts deleted file mode 100644 index a01fd0f104a..00000000000 --- a/frontend/app/src/shared/api/graphql/graphqlClientApollo.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { ApolloLink, execute, gql, Observable } from "@apollo/client"; -import type { GraphQLFormattedError } from "graphql"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { ERROR_CODES } from "@/shared/api/errors"; -import { PRIORITY_HEADER } from "@/shared/api/priority"; -import { queryClient } from "@/shared/api/rest/client"; - -import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from "@/entities/authentication/api/token-storage"; -import { __navigation } from "@/entities/authentication/domain/use-cases/redirect-to-login"; - -import { handleGraphQLAuthError, priorityLink } from "./graphqlClientApollo"; - -describe("handleGraphQLAuthError — TOKEN_EXPIRED retry-then-bail loop", () => { - // Minimal stand-in for Apollo's `Operation`. The handler only touches - // `getContext`/`setContext`, so we don't bring in the full Apollo type - // just to satisfy a structural shape. - function makeOperation() { - let ctx: Record = {}; - return { - getContext: () => ctx, - setContext: (patch: Record) => { - ctx = { ...ctx, ...patch }; - }, - }; - } - - const tokenExpiredError = { - message: "Token expired", - extensions: { code: ERROR_CODES.TOKEN_EXPIRED, http_status: 401, data: {} }, - } satisfies Partial as GraphQLFormattedError; - - let assignSpy: ReturnType; - let fetchQuerySpy: ReturnType; - let originalAssign: typeof __navigation.assign; - - beforeEach(() => { - localStorage.setItem(ACCESS_TOKEN_KEY, "old-token"); - localStorage.setItem(REFRESH_TOKEN_KEY, "old-refresh"); - - // Swap the navigation holder's `assign` so the handler's hard-nav lands - // on a spy rather than actually navigating the test page. Restored in - // afterEach so unrelated tests don't see the stub. - originalAssign = __navigation.assign; - assignSpy = vi.fn(); - __navigation.assign = assignSpy as unknown as typeof __navigation.assign; - - fetchQuerySpy = vi.spyOn(queryClient, "fetchQuery"); - }); - - afterEach(() => { - __navigation.assign = originalAssign; - vi.restoreAllMocks(); - localStorage.clear(); - }); - - it("first TOKEN_EXPIRED refreshes the token and replays with Bearer header", async () => { - // GIVEN a refresh that returns a fresh access token - fetchQuerySpy.mockResolvedValue({ access_token: "new-token", refresh_token: "new-refresh" }); - const operation = makeOperation(); - // Stand-in for Apollo's `forward` — emits a single empty result so the - // retry observable completes cleanly. - const forward = vi.fn(() => Observable.of({ data: null })); - - // WHEN the handler sees a TOKEN_EXPIRED for the first time on this op - const result = handleGraphQLAuthError({ - graphQLErrors: [tokenExpiredError], - operation, - forward, - } as any); - - // THEN it returns the retry observable - expect(result).toBeInstanceOf(Observable); - - // Drive the observable through to completion so the refresh promise - // and forward subscription get a chance to run. - await new Promise((resolve, reject) => { - (result as Observable).subscribe({ - complete: () => resolve(), - error: (err) => reject(err), - }); - }); - - // AND the replayed operation carries the Bearer-prefixed new token - const headers = (operation.getContext() as { headers?: { authorization?: string } }).headers; - expect(headers?.authorization).toBe("Bearer new-token"); - - expect(fetchQuerySpy).toHaveBeenCalledOnce(); - expect(forward).toHaveBeenCalledOnce(); - // AND the user is NOT bounced — the happy path completes cleanly - expect(assignSpy).not.toHaveBeenCalled(); - }); - - it("replayed result that still carries TOKEN_EXPIRED bails to /login", async () => { - // GIVEN a refresh that succeeds, but the replayed request still - // comes back with TOKEN_EXPIRED (clock skew, malformed refreshed - // token, server-side revoke between refresh and replay). Apollo's - // onError does NOT re-invoke our handler for this result, so the - // bail has to be detected inside `retryWithRefreshedToken` itself. - fetchQuerySpy.mockResolvedValue({ access_token: "new-token", refresh_token: "new-refresh" }); - const operation = makeOperation(); - const replayedResult = { errors: [tokenExpiredError] }; - const forward = vi.fn(() => Observable.of(replayedResult)); - - // WHEN the handler runs the retry path - const result = handleGraphQLAuthError({ - graphQLErrors: [tokenExpiredError], - operation, - forward, - } as any); - - // THEN the retry observable errors out with the persistence sentinel - await expect( - new Promise((resolve, reject) => { - (result as Observable).subscribe({ - next: () => {}, - complete: () => resolve(), - error: (err) => reject(err), - }); - }) - ).rejects.toThrow(/persisted/i); - - // AND the user is hard-navigated to /login with `?from=…` - expect(assignSpy).toHaveBeenCalledOnce(); - const target = assignSpy.mock.calls[0]?.[0] as string | undefined; - expect(target).toMatch(/^\/login\?from=/); - - // AND the stale credentials were cleared so the next mount won't loop - expect(localStorage.getItem(ACCESS_TOKEN_KEY)).toBeNull(); - expect(localStorage.getItem(REFRESH_TOKEN_KEY)).toBeNull(); - }); - - it("refresh failure clears tokens and bounces to /login", async () => { - // GIVEN a refresh that rejects (refresh token expired / server revoked) - fetchQuerySpy.mockRejectedValue(new Error("refresh failed")); - const operation = makeOperation(); - const forward = vi.fn(); - - // WHEN the handler runs the retry path - const result = handleGraphQLAuthError({ - graphQLErrors: [tokenExpiredError], - operation, - forward, - } as any); - - // THEN the retry observable errors out - await expect( - new Promise((resolve, reject) => { - (result as Observable).subscribe({ - complete: () => resolve(), - error: (err) => reject(err), - }); - }) - ).rejects.toThrow("refresh failed"); - - // AND the user is bounced to /login instead of being left signed-in - // against a session the server has already disowned. - expect(assignSpy).toHaveBeenCalledOnce(); - expect(localStorage.getItem(ACCESS_TOKEN_KEY)).toBeNull(); - expect(forward).not.toHaveBeenCalled(); - }); -}); - -describe("priorityLink — outbound X-Priority header", () => { - // A terminating capture link records the headers priorityLink produced. - function runThroughPriorityLink(context?: Record) { - let captured: Record | undefined; - - const captureLink = new ApolloLink((operation) => { - captured = operation.getContext().headers as Record; - return Observable.of({ data: null }); - }); - - const link = ApolloLink.from([priorityLink, captureLink]); - - return new Promise | undefined>((resolve, reject) => { - execute(link, { query: gql`{ __typename }`, context }).subscribe({ - complete: () => resolve(captured), - error: (err) => reject(err), - }); - }); - } - - it("stamps X-Priority: high when the operation has no context.priority", async () => { - const headers = await runThroughPriorityLink(); - expect(headers?.[PRIORITY_HEADER]).toBe("high"); - }); - - it("stamps X-Priority: low when the operation declares context.priority = low", async () => { - const headers = await runThroughPriorityLink({ priority: "low" }); - expect(headers?.[PRIORITY_HEADER]).toBe("low"); - }); -}); - -describe("retryWithRefreshedToken (via handleGraphQLAuthError) — X-Priority survives 401 replay", () => { - // Drive the real handler. It reads localStorage via getAccessToken, so stub - // it in node mode; the refresh itself is driven through the fetchQuery spy. - const tokenExpiredError = { - message: "Token expired", - extensions: { code: ERROR_CODES.TOKEN_EXPIRED, http_status: 401, data: {} }, - } satisfies Partial as GraphQLFormattedError; - - let fetchQuerySpy: ReturnType; - - beforeEach(() => { - vi.stubGlobal("localStorage", { - getItem: () => null, - setItem: () => {}, - removeItem: () => {}, - clear: () => {}, - }); - fetchQuerySpy = vi.spyOn(queryClient, "fetchQuery"); - }); - - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - // Mirrors the first-pass state the retry reads: the stamped X-Priority plus - // the now-expired auth header. - function makeOperationWithPriority() { - let ctx: Record = { - headers: { [PRIORITY_HEADER]: "high", authorization: "Bearer old-token" }, - }; - return { - getContext: () => ctx, - setContext: (patch: Record) => { - ctx = { ...ctx, ...patch }; - }, - }; - } - - it("re-carries the original X-Priority after the refresh+replay", async () => { - // GIVEN - fetchQuerySpy.mockResolvedValue({ access_token: "new-token", refresh_token: "new-refresh" }); - const operation = makeOperationWithPriority(); - const forward = vi.fn(() => Observable.of({ data: null })); - - // WHEN - const result = handleGraphQLAuthError({ - graphQLErrors: [tokenExpiredError], - operation, - forward, - } as any); - - await new Promise((resolve, reject) => { - (result as Observable).subscribe({ - complete: () => resolve(), - error: (err) => reject(err), - }); - }); - - // THEN - const headers = (operation.getContext() as { headers?: Record }).headers; - expect(headers?.[PRIORITY_HEADER]).toBe("high"); - expect(headers?.authorization).toBe("Bearer new-token"); - expect(forward).toHaveBeenCalledOnce(); - }); -}); diff --git a/frontend/app/src/shared/api/graphql/graphqlClientApollo.tsx b/frontend/app/src/shared/api/graphql/graphqlClientApollo.tsx deleted file mode 100644 index 401fd0b1e8b..00000000000 --- a/frontend/app/src/shared/api/graphql/graphqlClientApollo.tsx +++ /dev/null @@ -1,257 +0,0 @@ -import { - ApolloClient, - type DefaultOptions, - type FetchResult, - from, - InMemoryCache, - Observable, -} from "@apollo/client"; -import { setContext } from "@apollo/client/link/context"; -import { onError } from "@apollo/client/link/error"; -import createUploadLink from "apollo-upload-client/createUploadLink.mjs"; -import { toast } from "react-toastify"; - -import { ERROR_CODES, parseCatalogueError } from "@/shared/api/errors"; -import { PRIORITY_HEADER, resolvePriority } from "@/shared/api/priority"; -import { queryClient } from "@/shared/api/rest/client"; -import { ALERT_TYPES, Alert } from "@/shared/components/ui/alert"; -import { CONFIG } from "@/shared/config/config"; - -import { getAccessToken } from "@/entities/authentication/api/token-storage"; -import { redirectToLogin } from "@/entities/authentication/domain/use-cases/redirect-to-login"; -import { refreshAccessTokenQueryOptions } from "@/entities/authentication/ui/queries/refresh-access-token.query"; - -export const defaultOptions: DefaultOptions = { - watchQuery: { - fetchPolicy: "no-cache", - errorPolicy: "all", - }, - query: { - fetchPolicy: "no-cache", - errorPolicy: "all", - }, -}; - -// HTTP link with context to update graphql endpoint (supports file uploads) -const httpLink = createUploadLink({ - uri: (operation: { getContext: () => { branch?: string; date?: Date | null } }) => { - const context = operation.getContext(); - - return CONFIG.GRAPHQL_URL(context?.branch, context?.date); - }, -}); - -// Auth link to add headers -export const authLink = setContext((_, previousContext) => { - const { headers } = previousContext; - - // Get the token from the session storage - const accessToken = getAccessToken(); - - if (!accessToken) { - return { - headers, - }; - } - - return { - headers: { - ...headers, - authorization: `Bearer ${accessToken}`, - }, - }; -}); - -// The backend prioritizes requests by X-Priority under load, so stamp it -// on every operation — otherwise the frontend's traffic falls back to the -// server default and can't be told apart from other clients when it matters. -export const priorityLink = setContext((_, previousContext) => { - const { headers, priority } = previousContext; - - return { - headers: { - ...headers, - [PRIORITY_HEADER]: resolvePriority(priority), - }, - }; -}); - -type ErrorLinkArgs = Parameters[0]>[0]; - -// True iff a forwarded result still carries TOKEN_EXPIRED. Used inside -// `retryWithRefreshedToken` because Apollo's onError link routes results -// from the retried observable directly to the outer observer — it does -// NOT re-invoke `handleGraphQLAuthError`, so a persistent TOKEN_EXPIRED -// would otherwise leak through to the caller as a generic GraphQL error. -function resultHasTokenExpired(result: FetchResult): boolean { - return ( - result.errors?.some( - (e) => parseCatalogueError(e.extensions).code === ERROR_CODES.TOKEN_EXPIRED - ) ?? false - ); -} - -// Error link callback: route each catalogue code to its policy. The -// discriminated union is generated from `schema/error-catalogue.json` — -// regenerate with `pnpm generate:error-bindings`. Exported (not just inlined -// into `onError`) so tests can drive it directly without spinning up an -// Apollo link chain. -export function handleGraphQLAuthError({ - graphQLErrors, - operation, - forward, -}: ErrorLinkArgs): Observable | undefined { - if (!graphQLErrors) return; - - for (const graphQLError of graphQLErrors) { - const parsed = parseCatalogueError(graphQLError.extensions); - - console.error( - `[GraphQL error]: Code: ${parsed.code}, Message: ${graphQLError.message}, ` + - `Location: ${JSON.stringify(graphQLError.locations)}, Path: ${graphQLError.path}` - ); - - switch (parsed.code) { - case ERROR_CODES.TOKEN_EXPIRED: - // The retry loop is bounded by construction: Apollo's onError - // does not re-invoke this handler for results from the retried - // observable, so we get exactly one refresh+replay attempt per - // operation. Persistence is caught inside `retryWithRefreshedToken`. - return retryWithRefreshedToken(operation, forward); - - case ERROR_CODES.AUTHENTICATION_REQUIRED: - redirectToLogin(); - return; - - case ERROR_CODES.PERMISSION_DENIED: - // Silent — 403s are handled by route-level guards, not toasts. - // `continue` (not `return`) so any sibling errors in the same - // response still reach their handlers. - continue; - - case ERROR_CODES.UNDEFINED_ERROR: - // Catalogue gap: the backend returned a code we don't recognise. - // In dev builds, surface this loudly so engineers see it without - // having to dig through devtools — a console.warn pointing at - // where to register the code, plus a prefix on the toast so the - // miss is visible during manual testing. Prod stays silent - // (just the generic toast) to avoid leaking implementation noise. - if (import.meta.env.DEV) { - console.error( - "[catalogue gap] Unmatched error code surfaced as UNDEFINED_ERROR. " + - "Register it in backend/infrahub/errors/catalogue.py, regenerate " + - "the schema, and run `pnpm generate:error-bindings`.", - { message: graphQLError.message, extensions: graphQLError.extensions } - ); - notifyUser(graphQLError.message, operation); - return; - } - notifyUser(graphQLError.message, operation); - return; - - default: - notifyUser(graphQLError.message, operation); - } - } - - return; -} - -export const errorLink = onError(handleGraphQLAuthError); - -// Helper: refresh the access token and replay the operation. Lifted from -// the previous inline Observable block in errorLink; the only behaviour -// change is that a refresh resolving without an access_token now errors -// the observer instead of leaving it pending (the old code dropped the -// no-token branch silently and the request hung forever). -function retryWithRefreshedToken( - operation: Parameters[0]>[0]["operation"], - forward: Parameters[0]>[0]["forward"] -): Observable { - return new Observable((observer) => { - const oldHeaders = operation.getContext().headers; - - queryClient - .fetchQuery(refreshAccessTokenQueryOptions()) - .then((newToken) => { - if (!newToken?.access_token) { - // Refresh resolved but the server returned no token — treat it - // like a refresh failure: clear stale credentials and bounce to - // /login, otherwise the user is left signed-in against tokens - // the server has already disowned. - redirectToLogin(); - observer.error(new Error("Token refresh returned no access_token")); - return; - } - - operation.setContext({ - headers: { - ...oldHeaders, - authorization: `Bearer ${newToken.access_token}`, - }, - }); - - // Retry the failed request. Inspect the replayed result for a - // repeated TOKEN_EXPIRED — Apollo will not re-enter our handler - // for results that come back from this `forward` call, so this - // is the only place we can detect a persistent expiry (clock - // skew, malformed refreshed token, server-side revoke) and bail - // to /login instead of leaking the error to the caller. - forward(operation).subscribe({ - next: (result) => { - if (resultHasTokenExpired(result)) { - redirectToLogin(); - observer.error(new Error("TOKEN_EXPIRED persisted after refresh")); - return; - } - observer.next(result); - }, - error: observer.error.bind(observer), - complete: observer.complete.bind(observer), - }); - }) - .catch((err) => { - // Refresh itself failed (refresh token expired, network error, - // server-side revoke). Without this branch the caller saw a - // network error, kept the stale credentials in localStorage, - // and every subsequent query hit the same wall — the user was - // effectively stuck until they cleared storage by hand. Bounce - // to /login so they can re-authenticate. - redirectToLogin(); - observer.error(err); - }); - }); -} - -// Helper: surface an error to the user. Calls operation.context's -// processErrorMessage if present (caller-specific override), else toasts. -function notifyUser( - message: string | undefined, - operation: Parameters[0]>[0]["operation"] -) { - if (!message) return; - - const { processErrorMessage } = operation.getContext(); - - if (processErrorMessage) { - processErrorMessage(message); - return; - } - - toast(, { - toastId: "alert-error", - }); -} - -const graphqlClient = new ApolloClient({ - link: from([errorLink, authLink, priorityLink, httpLink]), - cache: new InMemoryCache(), - defaultOptions, - // Apollo is a transport-only layer here: queries run imperatively via - // graphqlClient.query (no Apollo hooks/cache) and are fronted by TanStack - // Query, which owns caching and request deduplication by queryKey. Disable - // Apollo's own in-flight dedup so TanStack is the single dedup authority. - queryDeduplication: false, -}); - -export default graphqlClient; diff --git a/frontend/app/src/shared/api/graphql/types.ts b/frontend/app/src/shared/api/graphql/types.ts new file mode 100644 index 00000000000..3b19f598536 --- /dev/null +++ b/frontend/app/src/shared/api/graphql/types.ts @@ -0,0 +1,13 @@ +import type { CombinedError } from "@urql/core"; + +export interface GraphQLRequestContext { + branch?: string | null; + date?: Date | null; + processErrorMessage?: (message: string) => void; +} + +export interface GraphQLResult { + data: TData; + error?: CombinedError; + errors?: Array<{ message: string }>; +} diff --git a/frontend/app/src/shared/api/rest/client.ts b/frontend/app/src/shared/api/rest/client.ts index af2fc944121..d907b96733b 100644 --- a/frontend/app/src/shared/api/rest/client.ts +++ b/frontend/app/src/shared/api/rest/client.ts @@ -58,13 +58,6 @@ export const authMiddleware: Middleware = { try { const newToken = await queryClient.fetchQuery(refreshAccessTokenQueryOptions()); - if (!newToken?.access_token) { - // Refresh resolved but server returned no token — treat as failure - // and bounce to /login, matching the Apollo errorLink behaviour. - redirectToLogin(); - return response; - } - clonedRequest.headers.set("Authorization", `Bearer ${newToken.access_token}`); return fetch(clonedRequest); } catch (error) { diff --git a/frontend/app/src/shared/components/inputs/dropdown.test.tsx b/frontend/app/src/shared/components/inputs/dropdown.test.tsx index 9f3d9d4606f..c04aaf56d5c 100644 --- a/frontend/app/src/shared/components/inputs/dropdown.test.tsx +++ b/frontend/app/src/shared/components/inputs/dropdown.test.tsx @@ -1,7 +1,5 @@ -import { ApolloProvider } from "@apollo/client"; import { afterEach, describe, expect, test } from "vitest"; -import graphqlClient from "@/shared/api/graphql/graphqlClientApollo"; import { store } from "@/shared/stores"; import type { AttributeSchema, ModelSchema } from "@/entities/schema/domain/model/schema"; @@ -16,9 +14,6 @@ const items = [ { value: "internal", label: "Internal" }, ]; -const renderDropdown = (ui: React.ReactElement) => - render({ui}); - describe("Dropdown delete button", () => { afterEach(() => { store.set(namespacesAtom, []); @@ -30,7 +25,7 @@ describe("Dropdown delete button", () => { const schema = { kind: "CoreStandardGroup", namespace: "Core" } as ModelSchema; // WHEN the dropdown options are shown - const component = await renderDropdown( + const component = await render( { const schema = { kind: "MyCustomNode", namespace: "Builtin" } as ModelSchema; // WHEN the dropdown options are shown - const component = await renderDropdown( + const component = await render( - render({ui}); - describe("Enum delete button", () => { afterEach(() => { store.set(namespacesAtom, []); @@ -26,7 +21,7 @@ describe("Enum delete button", () => { const schema = { kind: "CoreStandardGroup", namespace: "Core" } as ModelSchema; // WHEN the enum options are shown - const component = await renderEnum( + const component = await render( { const schema = { kind: "MyCustomNode", namespace: "Builtin" } as ModelSchema; // WHEN the enum options are shown - const component = await renderEnum( + const component = await render( =19.0.0-rc - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc - subscriptions-transport-ws: ^0.9.0 || ^0.11.0 - peerDependenciesMeta: - graphql-ws: - optional: true - react: - optional: true - react-dom: - optional: true - subscriptions-transport-ws: - optional: true - '@ardatan/relay-compiler@13.0.1': resolution: {integrity: sha512-afG3YPwuSA0E5foouZusz5GlXKs74dObv4cuWyLyfKsYFj2r7oGRNB28v18HvwuLSQtQFCi+DpIe0TZkgQDYyg==} peerDependencies: @@ -4528,9 +4507,6 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@types/apollo-upload-client@18.0.1': - resolution: {integrity: sha512-qumgUkhs9pqJAxlDtzmn3WTrJ9oAHBb6i9A7aR1HQyjLpX9+LRL5V84aErv5ZwcCSR2zEgG8cFsuBVYfZHFSRA==} - '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -4666,9 +4642,6 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/extract-files@13.0.2': - resolution: {integrity: sha512-4sd7uDB0OVZmwH2wD6w7Qlpr2P5Pn8C9IGwnaq9aiiBDD3Lou7CwFjjkJTDYCDsEvk9zxAtmv9TaMg1lt/YJfA==} - '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} @@ -4886,6 +4859,14 @@ packages: '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@urql/core@6.0.3': + resolution: {integrity: sha512-0F39XWR21+IVLJfsqBvLShvoSzxT6h6wWXwrDhN8j9J+IEVOpQIUNHwpgjsp+2ePzn9aCH+rpDonn3HOAUnn9A==} + + '@urql/exchange-auth@3.0.0': + resolution: {integrity: sha512-tj09xiOR2f1J2h8TE9uZWjRZipCdmDoTewEytOacDQ+0Teo+yIZxm3ppHxolQtiA51OHrGYiNTkMte8HtfvaBw==} + peerDependencies: + '@urql/core': ^6.0.0 + '@vitejs/plugin-react@6.0.4': resolution: {integrity: sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5028,22 +5009,6 @@ packages: resolution: {integrity: sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==} engines: {node: '>=16.0.0'} - '@wry/caches@1.0.1': - resolution: {integrity: sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==} - engines: {node: '>=8'} - - '@wry/context@0.7.4': - resolution: {integrity: sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==} - engines: {node: '>=8'} - - '@wry/equality@0.5.7': - resolution: {integrity: sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==} - engines: {node: '>=8'} - - '@wry/trie@0.5.0': - resolution: {integrity: sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==} - engines: {node: '>=8'} - '@xyflow/react@12.10.2': resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==} peerDependencies: @@ -5158,13 +5123,6 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - apollo-upload-client@18.0.1: - resolution: {integrity: sha512-OQvZg1rK05VNI79D658FUmMdoI2oB/KJKb6QGMa2Si25QXOaAvLMBFUEwJct7wf+19U8vk9ILhidBOU1ZWv6QA==} - engines: {node: ^18.15.0 || >=20.4.0} - peerDependencies: - '@apollo/client': ^3.8.0 - graphql: 14 - 16 - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -5979,10 +5937,6 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - extract-files@13.0.0: - resolution: {integrity: sha512-FXD+2Tsr8Iqtm3QZy1Zmwscca7Jx3mMC5Crr+sEP1I303Jy1CYMuYCm7hRTplFNg3XdUavErkxnTzpaqdSoi6g==} - engines: {node: ^14.17.0 || ^16.0.0 || >= 18.0.0} - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -6277,9 +6231,6 @@ packages: highlightjs-vue@1.0.0: resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==} - hoist-non-react-statics@3.3.2: - resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} - html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -7147,9 +7098,6 @@ packages: peerDependencies: typescript: ^5.x - optimism@0.18.1: - resolution: {integrity: sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -7684,17 +7632,6 @@ packages: refractor@5.0.0: resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==} - rehackt@0.1.0: - resolution: {integrity: sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw==} - peerDependencies: - '@types/react': '*' - react: '*' - peerDependenciesMeta: - '@types/react': - optional: true - react: - optional: true - rehype-mermaid@3.0.0: resolution: {integrity: sha512-fxrD5E4Fa1WXUjmjNDvLOMT4XB1WaxcfycFIWiYU0yEMQhcTDElc9aDFnbDFRLxG1Cfo1I3mfD5kg4sjlWaB+Q==} peerDependencies: @@ -8017,10 +7954,6 @@ packages: swap-case@3.0.3: resolution: {integrity: sha512-6p4op8wE9CQv7uDFzulI6YXUw4lD9n4oQierdbFThEKVWVQcbQcUjdP27W8XE7V4QnWmnq9jueSHceyyQnqQVA==} - symbol-observable@4.0.0: - resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} - engines: {node: '>=0.10'} - sync-fetch@0.6.0: resolution: {integrity: sha512-IELLEvzHuCfc1uTsshPK58ViSdNqXxlml1U+fmwJIKLYKOr/rAtBrorE2RYm5IHaMpDNlmC0fr1LAvdXvyheEQ==} engines: {node: '>=18'} @@ -8132,10 +8065,6 @@ packages: resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} engines: {node: '>=6.10'} - ts-invariant@0.10.3: - resolution: {integrity: sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==} - engines: {node: '>=8'} - ts-log@3.0.2: resolution: {integrity: sha512-esq6hx2lM66sQV1YcFkIYTqrWWabmqBqobKHyn1CswdI5FgfQhkmiKiRWVGBNlIbdjBxEIkNvMIwLKKPgRYZLQ==} engines: {node: '>=20', npm: '>=10'} @@ -8522,6 +8451,9 @@ packages: resolution: {integrity: sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==} engines: {node: '>=20'} + wonka@6.3.6: + resolution: {integrity: sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -8604,12 +8536,6 @@ packages: yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} - zen-observable-ts@1.2.5: - resolution: {integrity: sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==} - - zen-observable@0.8.15: - resolution: {integrity: sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==} - zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -8703,29 +8629,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@apollo/client@3.13.8(@types/react@19.2.17)(graphql-ws@6.0.8(graphql@16.14.2)(ws@8.21.0))(graphql@16.14.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2) - '@wry/caches': 1.0.1 - '@wry/equality': 0.5.7 - '@wry/trie': 0.5.0 - graphql: 16.14.2 - graphql-tag: 2.12.6(graphql@16.14.2) - hoist-non-react-statics: 3.3.2 - optimism: 0.18.1 - prop-types: 15.8.1 - rehackt: 0.1.0(@types/react@19.2.17)(react@19.2.8) - symbol-observable: 4.0.0 - ts-invariant: 0.10.3 - tslib: 2.8.1 - zen-observable-ts: 1.2.5 - optionalDependencies: - graphql-ws: 6.0.8(graphql@16.14.2)(ws@8.21.0) - react: 19.2.8 - react-dom: 19.2.8(react@19.2.8) - transitivePeerDependencies: - - '@types/react' - '@ardatan/relay-compiler@13.0.1(graphql@16.14.2)': dependencies: '@babel/runtime': 7.29.2 @@ -12066,18 +11969,6 @@ snapshots: tslib: 2.8.1 optional: true - '@types/apollo-upload-client@18.0.1(@types/react@19.2.17)(graphql-ws@6.0.8(graphql@16.14.2)(ws@8.21.0))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': - dependencies: - '@apollo/client': 3.13.8(@types/react@19.2.17)(graphql-ws@6.0.8(graphql@16.14.2)(ws@8.21.0))(graphql@16.14.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@types/extract-files': 13.0.2 - graphql: 16.14.2 - transitivePeerDependencies: - - '@types/react' - - graphql-ws - - react - - react-dom - - subscriptions-transport-ws - '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -12245,8 +12136,6 @@ snapshots: '@types/estree@1.0.9': {} - '@types/extract-files@13.0.2': {} - '@types/geojson@7946.0.16': {} '@types/hast@3.0.4': @@ -12511,6 +12400,18 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + '@urql/core@6.0.3(graphql@16.14.2)': + dependencies: + '@0no-co/graphql.web': 1.3.2(graphql@16.14.2) + wonka: 6.3.6 + transitivePeerDependencies: + - graphql + + '@urql/exchange-auth@3.0.0(@urql/core@6.0.3(graphql@16.14.2))': + dependencies: + '@urql/core': 6.0.3(graphql@16.14.2) + wonka: 6.3.6 + '@vitejs/plugin-react@6.0.4(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -12785,22 +12686,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@wry/caches@1.0.1': - dependencies: - tslib: 2.8.1 - - '@wry/context@0.7.4': - dependencies: - tslib: 2.8.1 - - '@wry/equality@0.5.7': - dependencies: - tslib: 2.8.1 - - '@wry/trie@0.5.0': - dependencies: - tslib: 2.8.1 - '@xyflow/react@12.10.2(@types/react@19.2.17)(immer@11.1.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@xyflow/system': 0.0.76 @@ -12943,12 +12828,6 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 - apollo-upload-client@18.0.1(@apollo/client@3.13.8(@types/react@19.2.17)(graphql-ws@6.0.8(graphql@16.14.2)(ws@8.21.0))(graphql@16.14.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(graphql@16.14.2): - dependencies: - '@apollo/client': 3.13.8(@types/react@19.2.17)(graphql-ws@6.0.8(graphql@16.14.2)(ws@8.21.0))(graphql@16.14.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - extract-files: 13.0.0 - graphql: 16.14.2 - argparse@2.0.1: {} aria-hidden@1.2.6: @@ -13765,10 +13644,6 @@ snapshots: extend@3.0.2: {} - extract-files@13.0.0: - dependencies: - is-plain-obj: 4.1.0 - fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -14113,10 +13988,6 @@ snapshots: highlightjs-vue@1.0.0: {} - hoist-non-react-statics@3.3.2: - dependencies: - react-is: 16.13.1 - html-escaper@2.0.2: {} html-to-image@1.11.13: {} @@ -15101,13 +14972,6 @@ snapshots: typescript: 5.9.3 yargs-parser: 21.1.1 - optimism@0.18.1: - dependencies: - '@wry/caches': 1.0.1 - '@wry/context': 0.7.4 - '@wry/trie': 0.5.0 - tslib: 2.8.1 - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -15917,11 +15781,6 @@ snapshots: hastscript: 9.0.1 parse-entities: 4.0.2 - rehackt@0.1.0(@types/react@19.2.17)(react@19.2.8): - optionalDependencies: - '@types/react': 19.2.17 - react: 19.2.8 - rehype-mermaid@3.0.0(playwright@1.60.0): dependencies: '@types/hast': 3.0.4 @@ -16282,8 +16141,6 @@ snapshots: swap-case@3.0.3: {} - symbol-observable@4.0.0: {} - sync-fetch@0.6.0: dependencies: node-fetch: 3.3.2 @@ -16356,10 +16213,6 @@ snapshots: ts-dedent@2.3.0: {} - ts-invariant@0.10.3: - dependencies: - tslib: 2.8.1 - ts-log@3.0.2: {} tsconfig-paths@4.2.0: @@ -16757,6 +16610,8 @@ snapshots: dependencies: string-width: 8.2.2 + wonka@6.3.6: {} + word-wrap@1.2.5: {} wrap-ansi@10.0.0: @@ -16808,12 +16663,6 @@ snapshots: yoga-layout@3.2.1: {} - zen-observable-ts@1.2.5: - dependencies: - zen-observable: 0.8.15 - - zen-observable@0.8.15: {} - zod-validation-error@4.0.2(zod@4.4.3): dependencies: zod: 4.4.3 From c06fc6e5f45c418262d975d319297fd6ca4e7d59 Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Thu, 30 Jul 2026 11:32:25 +0200 Subject: [PATCH 58/75] test(merge-recompute): assert cross-relationship HFID refresh via server lookup (#10069) The coalesced merge and rebase recompute already refreshes the stored cross-relationship human-friendly id. The earlier reproduction read InfrahubNode.hfid, which the SDK computes on the client side from a cached peer, so it looked stale while the server value was correct. Assert the server value through get_one_by_hfid: after merge and rebase the reader is found by its new id and no longer by the old one. Drop the xfail markers so the tests guard the behavior. Replace the two builder-level component tests with one unit test that checks the builder schedules the HFID recompute for a cross-relationship id. --- .../tests/helpers/merge_recompute/dataset.py | 103 ++++++++++- .../test_merge_recompute.py | 170 ++++++++++++++++++ .../merge/test_build_coalesced_recompute.py | 22 ++- 3 files changed, 290 insertions(+), 5 deletions(-) diff --git a/backend/tests/helpers/merge_recompute/dataset.py b/backend/tests/helpers/merge_recompute/dataset.py index 3b29abed2ba..2eece1b4647 100644 --- a/backend/tests/helpers/merge_recompute/dataset.py +++ b/backend/tests/helpers/merge_recompute/dataset.py @@ -22,8 +22,12 @@ PROFILE_PEER_KIND = "TestingProfilePeer" -def build_profile_schema() -> SchemaRoot: - """Two kinds: a peer, and a main node carrying all three derived families.""" +def build_profile_schema(cross_relationship_hfid: bool = False) -> SchemaRoot: + """Two kinds: a peer, and a main node carrying all three derived families. + + With ``cross_relationship_hfid`` the node's human-friendly id reads the peer across the + relationship instead of only its own name, so a peer rename has to refresh the stored HFID. + """ peer = NodeSchema( name="ProfilePeer", namespace=PROFILE_NAMESPACE, @@ -41,7 +45,7 @@ def build_profile_schema() -> SchemaRoot: label="Profile Node", default_filter="name__value", display_label="{{ name__value }} via {{ peer__name__value }}", - human_friendly_id=["name__value"], + human_friendly_id=["name__value", "peer__name__value"] if cross_relationship_hfid else ["name__value"], uniqueness_constraints=[["name__value"]], attributes=[ AttributeSchema(name="name", kind="Text", optional=False, unique=True), @@ -233,6 +237,99 @@ def build_chain_schema_dict(levels: int = 3) -> dict: return {"version": "1.0", "nodes": nodes} +INTERFACE_NAMESPACE = "Testing" +DEVICE_KIND = "TestingDevice" +INTERFACE_KIND = "TestingInterface" + + +def build_interface_hfid_schema_dict() -> dict: + """A device and an interface whose identity reads the device across the relationship. + + The interface's human-friendly id and display label both read the device name, so its stored + identity depends on a peer attribute. Renaming the device must rewrite the stored id, the + cross-relationship case that a self-only id never reaches. + """ + return { + "version": "1.0", + "nodes": [ + { + "name": "Device", + "namespace": INTERFACE_NAMESPACE, + "default_filter": "name__value", + "display_label": "{{ name__value }}", + "attributes": [{"name": "name", "kind": "Text", "optional": False, "unique": True}], + }, + { + "name": "Interface", + "namespace": INTERFACE_NAMESPACE, + "default_filter": "name__value", + "display_label": "{{ device__name__value }} :: {{ name__value }}", + "human_friendly_id": ["name__value", "device__name__value"], + "uniqueness_constraints": [["device", "name__value"]], + "attributes": [{"name": "name", "kind": "Text", "optional": False}], + "relationships": [{"name": "device", "peer": DEVICE_KIND, "optional": False, "cardinality": "one"}], + }, + ], + } + + +LOCATION_NAMESPACE = "Testing" +METRO_KIND = "TestingMetro" +SITE_KIND = "TestingSite" +RACK_KIND = "TestingRack" + + +def build_location_cascade_schema_dict() -> dict: + """A metro -> site -> rack chain that propagates a top-level rename two hops. + + The site's short name is a computed attribute reading the metro name across the relationship. + The site display label reads the metro directly, so it refreshes on the first hop. The rack + display label reads the site's short name across its own relationship, so it moves only after + the site's short name is rewritten: the recompute has to chain from that write to the rack. A + self-only display label never exercises this second hop. + """ + return { + "version": "1.0", + "nodes": [ + { + "name": "Metro", + "namespace": LOCATION_NAMESPACE, + "default_filter": "name__value", + "display_label": "{{ name__value }}", + "attributes": [{"name": "name", "kind": "Text", "optional": False, "unique": True}], + }, + { + "name": "Site", + "namespace": LOCATION_NAMESPACE, + "default_filter": "name__value", + "display_label": "{{ metro__name__value }}-{{ name__value }}", + "attributes": [ + {"name": "name", "kind": "Text", "optional": False, "unique": True}, + { + "name": "shortname", + "kind": "Text", + "optional": True, + "read_only": True, + "computed_attribute": { + "kind": "Jinja2", + "jinja2_template": "{{ metro__name__value }}-{{ name__value }}", + }, + }, + ], + "relationships": [{"name": "metro", "peer": METRO_KIND, "optional": False, "cardinality": "one"}], + }, + { + "name": "Rack", + "namespace": LOCATION_NAMESPACE, + "default_filter": "name__value", + "display_label": "{{ site__shortname__value }} :: {{ name__value }}", + "attributes": [{"name": "name", "kind": "Text", "optional": False, "unique": True}], + "relationships": [{"name": "site", "peer": SITE_KIND, "optional": False, "cardinality": "one"}], + }, + ], + } + + @dataclass(frozen=True) class SeededDataset: branch: Branch diff --git a/backend/tests/integration_docker/test_merge_recompute.py b/backend/tests/integration_docker/test_merge_recompute.py index 82939c5f391..9cf3e8ad5bf 100644 --- a/backend/tests/integration_docker/test_merge_recompute.py +++ b/backend/tests/integration_docker/test_merge_recompute.py @@ -10,9 +10,16 @@ from infrahub_sdk.testing.docker import TestInfrahubDockerClient from tests.helpers.merge_recompute.dataset import ( + DEVICE_KIND, + INTERFACE_KIND, + METRO_KIND, PROFILE_NODE_KIND, PROFILE_PEER_KIND, + RACK_KIND, + SITE_KIND, build_chain_schema_dict, + build_interface_hfid_schema_dict, + build_location_cascade_schema_dict, build_profile_schema_dict, chain_kind, ) @@ -68,6 +75,14 @@ def profile_schema(self) -> dict: def chain_schema(self) -> dict: return build_chain_schema_dict(levels=3) + @pytest.fixture(scope="class") + def interface_hfid_schema(self) -> dict: + return build_interface_hfid_schema_dict() + + @pytest.fixture(scope="class") + def location_cascade_schema(self) -> dict: + return build_location_cascade_schema_dict() + @pytest.fixture(scope="class") def delete_peer_schema(self) -> dict: """Reuse the TShirt and Color helpers, dropping the transform-python attribute (it needs a transform repo) and making color optional.""" @@ -219,6 +234,75 @@ async def _on_destination() -> bool: assert final.display_label == "cnode via gamma" assert final.hfid == ["cnode"] + # Cross-relationship human-friendly id: the reader's identity reads a peer across the relationship, + # so a peer rename must reindex the stored hfid that backs get_one_by_hfid. These assert the stored + # value through the id lookup, not the id the SDK recomputes client-side from the loaded peer. + + async def test_merge_recomputes_cross_relationship_hfid( + self, client: InfrahubClient, interface_hfid_schema: dict + ) -> None: + """A merged peer rename reindexes the reader: it resolves under the new hfid and not the old one.""" + loaded = await client.schema.load(schemas=[interface_hfid_schema], wait_until_converged=True) + assert loaded.schema_updated + + device = await client.create(kind=DEVICE_KIND, data={"name": "device-a"}) + await device.save() + interface = await client.create(kind=INTERFACE_KIND, data={"name": "eth1", "device": device}) + await interface.save() + + async def _resolves(hfid: list[str]) -> bool: + return await client.get(kind=INTERFACE_KIND, hfid=hfid, raise_when_missing=False) is not None + + await _wait_until(lambda: _resolves(["eth1", "device-a"])) + + branch = await client.branch.create(branch_name="hfid-merge-correctness") + device_on_branch = await client.get(kind=DEVICE_KIND, id=device.id, branch=branch.name) + device_on_branch.name.value = "device-b" + await device_on_branch.save() + + merged = await client.branch.merge(branch_name=branch.name) + assert merged + await _wait_until_merged(client=client, branch_name=branch.name) + + assert await _became_true(lambda: _resolves(["eth1", "device-b"]), seconds=90) + assert (await client.get(kind=INTERFACE_KIND, hfid=["eth1", "device-b"])).id == interface.id + assert not await _resolves(["eth1", "device-a"]) + + async def test_rebase_recomputes_cross_relationship_hfid( + self, client: InfrahubClient, interface_hfid_schema: dict + ) -> None: + """A rebase that replays a peer rename reindexes a user-branch reader under its new hfid.""" + await client.schema.load(schemas=[interface_hfid_schema], wait_until_converged=True) + + device = await client.create(kind=DEVICE_KIND, data={"name": "rdevice-a"}) + await device.save() + + branch = await client.branch.create(branch_name="hfid-rebase-correctness") + + # Rename the device on the default branch; the rebase replays this onto the user branch. + device.name.value = "rdevice-b" + await device.save() + + # The interface lives only on the user branch, so only the rebase recompute can refresh it. + interface = await client.create( + kind=INTERFACE_KIND, data={"name": "reth1", "device": device}, branch=branch.name + ) + await interface.save() + + async def _resolves(hfid: list[str]) -> bool: + found = await client.get(kind=INTERFACE_KIND, hfid=hfid, branch=branch.name, raise_when_missing=False) + return found is not None + + await _wait_until(lambda: _resolves(["reth1", "rdevice-a"])) + + await client.branch.rebase(branch_name=branch.name) + + assert await _became_true(lambda: _resolves(["reth1", "rdevice-b"]), seconds=90) + assert ( + await client.get(kind=INTERFACE_KIND, hfid=["reth1", "rdevice-b"], branch=branch.name) + ).id == interface.id + assert not await _resolves(["reth1", "rdevice-a"]) + @pytest.mark.xfail( strict=True, reason=( @@ -384,6 +468,92 @@ async def _chain_on_branch_is(value: str) -> bool: assert final_mid.summary.value == "rroot-edited" assert final_tip.summary.value == "rroot-edited" + # Display-label cascade: a rename two hops up must refresh display labels down the chain. + + async def test_merge_recomputes_two_level_display_label_cascade( + self, client: InfrahubClient, location_cascade_schema: dict + ) -> None: + """A top-level rename must refresh both levels' display labels below it after merge.""" + loaded = await client.schema.load(schemas=[location_cascade_schema], wait_until_converged=True) + assert loaded.schema_updated + + metro = await client.create(kind=METRO_KIND, data={"name": "metro-a"}) + await metro.save() + site = await client.create(kind=SITE_KIND, data={"name": "site1", "metro": metro}) + await site.save() + rack = await client.create(kind=RACK_KIND, data={"name": "rack1", "site": site}) + await rack.save() + + async def _labels_are(metro_name: str) -> bool: + site_node = await client.get(kind=SITE_KIND, id=site.id) + rack_node = await client.get(kind=RACK_KIND, id=rack.id) + return ( + site_node.display_label == f"{metro_name}-site1" + and rack_node.display_label == f"{metro_name}-site1 :: rack1" + ) + + await _wait_until(lambda: _labels_are("metro-a")) + + branch = await client.branch.create(branch_name="cascade-merge-correctness") + metro_on_branch = await client.get(kind=METRO_KIND, id=metro.id, branch=branch.name) + metro_on_branch.name.value = "metro-b" + await metro_on_branch.save() + + merged = await client.branch.merge(branch_name=branch.name) + assert merged + await _wait_until_merged(client=client, branch_name=branch.name) + + await _wait_until(lambda: _labels_are("metro-b")) + + final_site = await client.get(kind=SITE_KIND, id=site.id) + final_rack = await client.get(kind=RACK_KIND, id=rack.id) + # First hop: the site reads the metro across its relationship. Second hop: the rack reads the + # site's short name, which only moves once the site's recompute writes it, so the rack + # refreshes only if the recompute chains from the site's write to the rack. + assert final_site.shortname.value == "metro-b-site1" + assert final_site.display_label == "metro-b-site1" + assert final_rack.display_label == "metro-b-site1 :: rack1" + + async def test_rebase_recomputes_two_level_display_label_cascade( + self, client: InfrahubClient, location_cascade_schema: dict + ) -> None: + """A rebase that replays a top-level rename must refresh both levels' display labels on the user branch.""" + await client.schema.load(schemas=[location_cascade_schema], wait_until_converged=True) + + metro = await client.create(kind=METRO_KIND, data={"name": "rmetro-a"}) + await metro.save() + + branch = await client.branch.create(branch_name="cascade-rebase-correctness") + # The site and rack live only on the user branch, so only the rebase recompute can refresh them. + site = await client.create(kind=SITE_KIND, data={"name": "rsite1", "metro": metro}, branch=branch.name) + await site.save() + rack = await client.create(kind=RACK_KIND, data={"name": "rrack1", "site": site}, branch=branch.name) + await rack.save() + + async def _labels_on_branch_are(metro_name: str) -> bool: + site_node = await client.get(kind=SITE_KIND, id=site.id, branch=branch.name) + rack_node = await client.get(kind=RACK_KIND, id=rack.id, branch=branch.name) + return ( + site_node.display_label == f"{metro_name}-rsite1" + and rack_node.display_label == f"{metro_name}-rsite1 :: rrack1" + ) + + await _wait_until(lambda: _labels_on_branch_are("rmetro-a")) + + # The rebase replays the default branch's metro rename onto the user branch. + metro.name.value = "rmetro-b" + await metro.save() + + await client.branch.rebase(branch_name=branch.name) + + await _wait_until(lambda: _labels_on_branch_are("rmetro-b")) + + final_site = await client.get(kind=SITE_KIND, id=site.id, branch=branch.name) + final_rack = await client.get(kind=RACK_KIND, id=rack.id, branch=branch.name) + assert final_site.shortname.value == "rmetro-b-rsite1" + assert final_site.display_label == "rmetro-b-rsite1" + assert final_rack.display_label == "rmetro-b-rsite1 :: rrack1" + # Delete a read peer: the merge must complete instead of erroring on the missing peer. async def test_merge_survives_deleting_a_read_peer(self, client: InfrahubClient, delete_peer_schema: dict) -> None: diff --git a/backend/tests/unit/core/merge/test_build_coalesced_recompute.py b/backend/tests/unit/core/merge/test_build_coalesced_recompute.py index fdb3c0fb78c..db5b41f7e18 100644 --- a/backend/tests/unit/core/merge/test_build_coalesced_recompute.py +++ b/backend/tests/unit/core/merge/test_build_coalesced_recompute.py @@ -19,9 +19,9 @@ from tests.helpers.merge_recompute.dataset import PROFILE_NODE_KIND, PROFILE_PEER_KIND, build_profile_schema -def _profile_schema_branch() -> SchemaBranch: +def _profile_schema_branch(cross_relationship_hfid: bool = False) -> SchemaBranch: schema_branch = SchemaBranch(cache={}, name="test") - schema_branch.load_schema(schema=build_profile_schema()) + schema_branch.load_schema(schema=build_profile_schema(cross_relationship_hfid=cross_relationship_hfid)) schema_branch.process() return schema_branch @@ -117,6 +117,24 @@ def test_hfid_does_not_fan_out_on_related_change() -> None: assert not any(target.family == HFID for target in result.targets) +def test_hfid_fans_out_when_id_crosses_relationship() -> None: + """A human-friendly id that reads a peer across the relationship is recomputed by that peer's change. + + A merge and a rebase reach the builder identically, so this one derivation covers both: the reader's + id is scheduled alongside the display label and computed attribute that read the same peer. + """ + builder = CoalescedRecomputeBuilder(schema_branch=_profile_schema_branch(cross_relationship_hfid=True)) + changes = [ + MergeChange(node_id="peer-0", kind=PROFILE_PEER_KIND, action="updated", changed_fields=frozenset({"name"})) + ] + + result = builder.build(changes=changes, branch="main") + + hfid = _by_identity(result)[HFID, PROFILE_NODE_KIND, None] + assert hfid.reads_across_relationship is True + assert _lookups(hfid) == {(PROFILE_PEER_KIND, "peer__ids", frozenset({"peer-0"}))} + + def test_changes_to_same_target_are_deduplicated() -> None: """An update and a deletion of peers reach the same target once, with their ids unioned.""" builder = CoalescedRecomputeBuilder(schema_branch=_profile_schema_branch()) From dfdbf126033e6d3ffa38ad74a691b6dd0f8631ef Mon Sep 17 00:00:00 2001 From: Pete Crocker Date: Thu, 30 Jul 2026 12:21:42 +0100 Subject: [PATCH 59/75] ci: dispatch Infrahub releases to infrahub-demo-sp (#10076) infrahub-demo-sp already has the receiving update-infrahub.yml and update-infrahub-sdk.yml workflows, but it was never added to this matrix, so they have never fired. Adding it means a new Infrahub release opens a version-bump PR there, whose CI runs the demo's integration suite against the release. --- .github/workflows/repository-dispatch.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/repository-dispatch.yml b/.github/workflows/repository-dispatch.yml index b1a1fa7b668..820d75d47eb 100644 --- a/.github/workflows/repository-dispatch.yml +++ b/.github/workflows/repository-dispatch.yml @@ -36,6 +36,7 @@ jobs: # Either a literal path, or the name of a secret... repo: - "opsmill/infrahub-demo-dc" + - "opsmill/infrahub-demo-sp" - "INFRAHUB_PACKER_REPOSITORY" - "INFRAHUB_ENTERPRISE_REPOSITORY" - "INFRAHUB_CUSTOMER1_REPOSITORY" From 9306bef5717a31a99c073793c75ece07810a7e06 Mon Sep 17 00:00:00 2001 From: Guillaume Mazoyer Date: Thu, 30 Jul 2026 13:35:06 +0200 Subject: [PATCH 60/75] Refresh a reader's derived values when its read peer is deleted (#9845) Deleting a node that other nodes read across a relationship left the readers with derived values (display label, human-friendly id, computed attribute) that still named the deleted node after a merge or rebase. An update whose changed fields include a relationship now also recomputes the node's own derived values that read across it, keyed by the node's own id. This covers both merge and rebase through the shared coalesced deriver, and attribute-only updates are unchanged. Relationship resolution also tolerates profile and template kinds, so such a node in the change set no longer aborts the whole coalesced recompute. --- .../core/merge/recompute_coalescing.py | 82 ++++++++++++++---- .../test_merge_submits_coalesced.py | 73 +++++++++++++++- .../test_merge_recompute.py | 29 +++---- .../merge/test_build_coalesced_recompute.py | 83 ++++++++++++++++--- .../+recompute-deleted-read-peer.fixed.md | 1 + 5 files changed, 221 insertions(+), 47 deletions(-) create mode 100644 changelog/+recompute-deleted-read-peer.fixed.md diff --git a/backend/infrahub/core/merge/recompute_coalescing.py b/backend/infrahub/core/merge/recompute_coalescing.py index c00bbba574e..949e099b39b 100644 --- a/backend/infrahub/core/merge/recompute_coalescing.py +++ b/backend/infrahub/core/merge/recompute_coalescing.py @@ -221,24 +221,70 @@ def build(self, *, changes: Iterable[MergeChange], branch: str) -> CoalescedReco def _resolve_targets(self, *, signature: ChangeSignature) -> Iterator[_ResolvedTarget]: if signature.action == CREATED: - include_self, include_cross = True, False - fields: frozenset[str] | None = None - precise = True - elif signature.action == UPDATED: - include_self, include_cross = False, True - # An update with no recorded fields cannot be scoped, so fall back to every - # field rather than risk missing a reader (over-recompute is safe). - fields = signature.changed_fields or None - precise = bool(signature.changed_fields) - elif signature.action == DELETED: - include_self, include_cross = False, True - fields = None - precise = True - else: - raise ValueError(f"Unknown change action: {signature.action!r}") + yield from self._derive_family_targets( + kind=signature.kind, fields=None, include_self=True, include_cross=False, precise=True + ) + return + if signature.action == DELETED: + yield from self._derive_family_targets( + kind=signature.kind, fields=None, include_self=False, include_cross=True, precise=True + ) + return + if signature.action == UPDATED: + if not signature.changed_fields: + # No fields to scope on: recompute self and cross, since the unknown change may be a + # relationship the node reads and under-recompute is not acceptable. + yield from self._derive_family_targets( + kind=signature.kind, fields=None, include_self=True, include_cross=True, precise=False + ) + return + # The node refreshed its own values inline on the save; only cross-node readers remain. + yield from self._derive_family_targets( + kind=signature.kind, + fields=signature.changed_fields, + include_self=False, + include_cross=True, + precise=True, + ) + # A relationship change that doesn't save the reader (e.g. a peer deleted on another branch) + # skips the reader's inline recompute, so refresh its own values here. + relationship_fields = self._changed_relationship_fields( + kind=signature.kind, changed_fields=signature.changed_fields + ) + if relationship_fields: + yield from self._derive_family_targets( + kind=signature.kind, + fields=relationship_fields, + include_self=True, + include_cross=False, + precise=True, + ) + return + raise ValueError(f"Unknown change action: {signature.action!r}") + + def _changed_relationship_fields(self, *, kind: str, changed_fields: frozenset[str]) -> frozenset[str] | None: + """Return the changed fields that name a relationship on ``kind`` (None if none). + + Any node-like kind (profiles and templates included) carries relationships, and a kind absent + from the branch yields None instead of raising. + """ + if not self.schema_branch.has(name=kind): + return None + node_schema = self.schema_branch.get(name=kind, duplicate=False) + matched = changed_fields & {relationship.name for relationship in node_schema.relationships} + return frozenset(matched) or None + def _derive_family_targets( + self, + *, + kind: str, + fields: frozenset[str] | None, + include_self: bool, + include_cross: bool, + precise: bool, + ) -> Iterator[_ResolvedTarget]: yield from self._resolve_computed_targets( - kind=signature.kind, + kind=kind, fields=fields, include_self=include_self, include_cross=include_cross, @@ -247,7 +293,7 @@ def _resolve_targets(self, *, signature: ChangeSignature) -> Iterator[_ResolvedT for display_target in derive_display_label_targets( display_labels=self.schema_branch.display_labels, - kind=signature.kind, + kind=kind, changed_fields=fields, include_self=include_self, include_cross=include_cross, @@ -263,7 +309,7 @@ def _resolve_targets(self, *, signature: ChangeSignature) -> Iterator[_ResolvedT for hfid_target in derive_hfid_targets( hfids=self.schema_branch.hfids, - kind=signature.kind, + kind=kind, changed_fields=fields, include_self=include_self, include_cross=include_cross, diff --git a/backend/tests/component/merge_recompute_coalescing/test_merge_submits_coalesced.py b/backend/tests/component/merge_recompute_coalescing/test_merge_submits_coalesced.py index 48d835d718b..3e799587a15 100644 --- a/backend/tests/component/merge_recompute_coalescing/test_merge_submits_coalesced.py +++ b/backend/tests/component/merge_recompute_coalescing/test_merge_submits_coalesced.py @@ -12,6 +12,9 @@ from infrahub.core.branch import Branch from infrahub.core.branch.tasks import merge_branch, rebase_branch from infrahub.core.diff.coordinator import DiffCoordinator +from infrahub.core.initialization import create_branch +from infrahub.core.manager import NodeManager +from infrahub.core.node import Node from infrahub.dependencies.registry import get_component_registry from infrahub.workers.dependencies import build_cache, build_database, build_event_service, build_workflow from infrahub.workflows.catalogue import ( @@ -22,7 +25,14 @@ from tests.adapters.cache import MemoryCache from tests.adapters.event import MemoryInfrahubEvent from tests.adapters.workflow import WorkflowRecorder -from tests.helpers.merge_recompute.dataset import load_profile_schema, seed_branch +from tests.helpers.merge_recompute.dataset import ( + PROFILE_NODE_KIND, + PROFILE_PEER_KIND, + build_profile_schema, + load_profile_schema, + seed_branch, +) +from tests.helpers.schema import load_schema if TYPE_CHECKING: from fast_depends import Provider @@ -143,3 +153,64 @@ async def test_rebase_submits_one_coalesced_recompute_per_target( # A rebase recomputes on the user branch, not the destination. assert computed[0]["parameters"]["branch_name"] == seeded.branch_name assert display[0]["parameters"]["branch_name"] == seeded.branch_name + + +async def test_merge_delete_peer_coalesces_reader_recompute_by_own_id( + db: InfrahubDatabase, + default_branch: Branch, + register_core_models_schema: SchemaBranch, + dependency_provider: Provider, +) -> None: + lock.initialize_lock(local_only=True) + + # Optional peer so it can be deleted while the reader survives. + schema = build_profile_schema() + node_schema = next(node for node in schema.nodes if node.kind == PROFILE_NODE_KIND) + node_schema.relationships[0].optional = True + await load_schema(db=db, schema=schema, update_db=True) + + peer = await Node.init(db=db, schema=PROFILE_PEER_KIND, branch=default_branch) + await peer.new(db=db, name="beta") + await peer.save(db=db) + # Several readers of the same peer, to prove the deletion coalesces them rather than fanning out. + reader_ids: list[str] = [] + for index in range(3): + reader = await Node.init(db=db, schema=PROFILE_NODE_KIND, branch=default_branch) + await reader.new(db=db, name=f"reader-{index}", peer=peer) + await reader.save(db=db) + reader_ids.append(reader.id) + + branch = await create_branch(branch_name="delete-peer-submit", db=db) + peer_on_branch = await NodeManager.get_one(id=peer.id, db=db, branch=branch) + assert peer_on_branch is not None + await peer_on_branch.delete(db=db) + + component_registry = get_component_registry() + diff_coordinator = await component_registry.get_component(DiffCoordinator, db=db, branch=branch) + await diff_coordinator.update_branch_diff(base_branch=default_branch, diff_branch=branch) + + recorder = WorkflowRecorder() + event_recorder = MemoryInfrahubEvent() + cache = MemoryCache() + context = InfrahubContext.init( + branch=default_branch, + account=AccountSession(account_id=str(uuid4()), auth_type=AuthType.NONE), + ) + with ( + dependency_provider.scope(build_database, lambda singleton=True: db), # noqa: ARG005 + dependency_provider.scope(build_event_service, lambda: event_recorder), + dependency_provider.scope(build_workflow, lambda: recorder), + dependency_provider.scope(build_cache, lambda: cache), + ): + await merge_branch(branch=branch.name, context=context) + + # The reverse lookup from the deleted peer finds no readers once its edges close, so the readers + # must be recomputed by their own ids, coalesced into one submission per family. + for workflow in (COMPUTED_ATTRIBUTE_PROCESS_JINJA2, DISPLAY_LABELS_PROCESS_JINJA2): + own_id_submissions = [ + call + for call in recorder.get_submit_calls_for(workflow) + if call["parameters"]["node_kind"] == PROFILE_NODE_KIND + ] + assert len(own_id_submissions) == 1, f"{workflow.name} fanned out instead of coalescing the readers" + assert sorted(own_id_submissions[0]["parameters"]["object_ids"]) == sorted(reader_ids) diff --git a/backend/tests/integration_docker/test_merge_recompute.py b/backend/tests/integration_docker/test_merge_recompute.py index 9cf3e8ad5bf..e14e5e5b616 100644 --- a/backend/tests/integration_docker/test_merge_recompute.py +++ b/backend/tests/integration_docker/test_merge_recompute.py @@ -303,32 +303,24 @@ async def _resolves(hfid: list[str]) -> bool: ).id == interface.id assert not await _resolves(["reth1", "rdevice-a"]) - @pytest.mark.xfail( - strict=True, - reason=( - "the recompute locates readers with a reverse relationship query that returns nothing " - "once the deleted peer's edges are closed, so the reader keeps a value that still names " - "the deleted peer" - ), - ) async def test_deleting_read_peer_refreshes_reader_after_merge(self, client: InfrahubClient) -> None: - """After a read peer is deleted and merged, the reader's derived values should stop naming it.""" + """After a read peer is deleted and merged, the reader's computed attribute stops naming it.""" schema = build_profile_schema_dict() node_schema = schema["nodes"][1] node_schema["relationships"][0]["optional"] = True - # Self-only display label so the scenario exercises the stale computed value, not the separate - # missing-peer diff crash that is fixed on its own path. + # Local display label: this test asserts the computed attribute's refresh, not the separate + # missing-peer diff crash for a cross-relationship label, which is fixed on its own path. node_schema["display_label"] = "{{ name__value }}" await client.schema.load(schemas=[schema], wait_until_converged=True) peer = await client.create(kind=PROFILE_PEER_KIND, data={"name": "beta"}) await peer.save() - node = await client.create(kind=PROFILE_NODE_KIND, data={"name": "node2", "peer": peer}) + node = await client.create(kind=PROFILE_NODE_KIND, data={"name": "dnode", "peer": peer}) await node.save() async def _reader_initial() -> bool: refreshed = await client.get(kind=PROFILE_NODE_KIND, id=node.id) - return refreshed.summary.value == "node2 on beta" + return refreshed.summary.value == "dnode on beta" await _wait_until(_reader_initial) @@ -340,11 +332,16 @@ async def _reader_initial() -> bool: assert merged await _wait_until_merged(client=client, branch_name=branch.name) - async def _reader_no_longer_names_peer() -> bool: + async def _reader_refreshed() -> bool: refreshed = await client.get(kind=PROFILE_NODE_KIND, id=node.id) - return refreshed.summary.value != "node2 on beta" + return refreshed.summary.value == "dnode on None" - assert await _became_true(_reader_no_longer_names_peer, seconds=60) + await _wait_until(_reader_refreshed) + + final = await client.get(kind=PROFILE_NODE_KIND, id=node.id) + assert final.summary.value == "dnode on None" + # HFID reads only the local name, so the delete leaves it unchanged. + assert final.hfid == ["dnode"] # Multi-level chain: level i reads level i-1 across the source relationship. diff --git a/backend/tests/unit/core/merge/test_build_coalesced_recompute.py b/backend/tests/unit/core/merge/test_build_coalesced_recompute.py index db5b41f7e18..188ed84d1cd 100644 --- a/backend/tests/unit/core/merge/test_build_coalesced_recompute.py +++ b/backend/tests/unit/core/merge/test_build_coalesced_recompute.py @@ -35,13 +35,31 @@ def _lookups(target: AffectedTarget) -> set[tuple[str, str, frozenset[str]]]: def test_cross_node_update_coalesces_readers() -> None: - """Many changed peers collapse to one computed and one display target, one union lookup each.""" - builder = CoalescedRecomputeBuilder(schema_branch=_profile_schema_branch()) + """Many changed peers collapse to one computed and one display target, one union lookup each. + + The change set also carries kinds the derivation must tolerate rather than abort on: an updated + profile kind (not a NodeSchema) and a kind absent from the branch. Neither adds a target here. + """ + schema_branch = _profile_schema_branch() + builder = CoalescedRecomputeBuilder(schema_branch=schema_branch) peer_ids = {f"peer-{index:02d}" for index in range(5)} changes = [ MergeChange(node_id=peer_id, kind=PROFILE_PEER_KIND, action="updated", changed_fields=frozenset({"name"})) for peer_id in peer_ids ] + changes.extend( + [ + MergeChange( + node_id="profile-0", + kind=next(iter(schema_branch.profiles)), + action="updated", + changed_fields=frozenset({"name"}), + ), + MergeChange( + node_id="ghost-0", kind="TestingMissingKind", action="updated", changed_fields=frozenset({"name"}) + ), + ] + ) result = builder.build(changes=changes, branch="main") @@ -105,6 +123,33 @@ def test_deleted_node_refreshes_readers() -> None: assert _lookups(target) == {(PROFILE_PEER_KIND, "peer__ids", frozenset({"peer-gone"}))} +def test_relationship_change_recomputes_own_values_by_id() -> None: + """A relationship in an update's changed fields recomputes the node's own derived values by its id. + + This is the reader whose peer was deleted: it appears as an update on the relationship but was + never saved, so its cross-relationship values are not refreshed inline. + """ + builder = CoalescedRecomputeBuilder(schema_branch=_profile_schema_branch()) + changes = [ + MergeChange(node_id="node-0", kind=PROFILE_NODE_KIND, action="updated", changed_fields=frozenset({"peer"})) + ] + + result = builder.build(changes=changes, branch="main") + + # The relationship is in the changed fields, so this is the precise self path, not the fallback. + assert result.fallback_used is False + by_identity = _by_identity(result) + own = {(PROFILE_NODE_KIND, "ids", frozenset({"node-0"}))} + assert set(by_identity) == { + (COMPUTED_ATTRIBUTE, PROFILE_NODE_KIND, "summary"), + (DISPLAY_LABEL, PROFILE_NODE_KIND, None), + (HFID, PROFILE_NODE_KIND, None), + } + for target in by_identity.values(): + assert target.reads_across_relationship is False + assert _lookups(target) == own + + def test_hfid_does_not_fan_out_on_related_change() -> None: """A human-friendly id built from the local name is never recomputed by a related node's change.""" builder = CoalescedRecomputeBuilder(schema_branch=_profile_schema_branch()) @@ -149,18 +194,32 @@ def test_changes_to_same_target_are_deduplicated() -> None: assert _lookups(computed) == {(PROFILE_PEER_KIND, "peer__ids", frozenset({"peer-0", "peer-1"}))} -def test_update_without_fields_is_a_bounded_fallback() -> None: - """An update with no recorded fields recomputes every cross-node reader and is marked imprecise.""" +def test_unscoped_update_is_a_bounded_fallback() -> None: + """An unscoped update imprecisely recomputes cross-node readers and the node's own derived values.""" builder = CoalescedRecomputeBuilder(schema_branch=_profile_schema_branch()) - changes = [MergeChange(node_id="peer-0", kind=PROFILE_PEER_KIND, action="updated", changed_fields=frozenset())] - - result = builder.build(changes=changes, branch="main") - # A peer's readers are the node computed summary and display label that read it across the - # relationship; no human-friendly id reads the peer, so even the unscoped fallback omits it. - assert set(_by_identity(result)) == { + # A peer: its cross-node readers (node summary and display) plus its own derived values. + peer_result = builder.build( + changes=[MergeChange(node_id="peer-0", kind=PROFILE_PEER_KIND, action="updated", changed_fields=frozenset())], + branch="main", + ) + assert set(_by_identity(peer_result)) == { (COMPUTED_ATTRIBUTE, PROFILE_NODE_KIND, "summary"), (DISPLAY_LABEL, PROFILE_NODE_KIND, None), + (DISPLAY_LABEL, PROFILE_PEER_KIND, None), + (HFID, PROFILE_PEER_KIND, None), } - assert result.fallback_used is True - assert all(target.precise is False for target in result.targets) + assert peer_result.fallback_used is True + assert all(target.precise is False for target in peer_result.targets) + + # A node: its own derived values, keyed by its own id. + node_result = builder.build( + changes=[MergeChange(node_id="node-0", kind=PROFILE_NODE_KIND, action="updated", changed_fields=frozenset())], + branch="main", + ) + by_identity = _by_identity(node_result) + own = {(PROFILE_NODE_KIND, "ids", frozenset({"node-0"}))} + assert _lookups(by_identity[COMPUTED_ATTRIBUTE, PROFILE_NODE_KIND, "summary"]) == own + assert _lookups(by_identity[DISPLAY_LABEL, PROFILE_NODE_KIND, None]) == own + assert _lookups(by_identity[HFID, PROFILE_NODE_KIND, None]) == own + assert node_result.fallback_used is True diff --git a/changelog/+recompute-deleted-read-peer.fixed.md b/changelog/+recompute-deleted-read-peer.fixed.md new file mode 100644 index 00000000000..9dc087b96dc --- /dev/null +++ b/changelog/+recompute-deleted-read-peer.fixed.md @@ -0,0 +1 @@ +Merging or rebasing a branch that deletes a node now refreshes the derived values of the nodes that read the deleted node across a relationship. Their computed attributes, display labels, and human-friendly ids no longer keep naming the deleted node. From ccc94caf8d4582c497be3e8d8c0e5aea37a4b1f2 Mon Sep 17 00:00:00 2001 From: Patrick Ogenstad Date: Thu, 30 Jul 2026 14:10:31 +0200 Subject: [PATCH 61/75] Fix Jinja2 comparison during repository imports --- backend/infrahub/git/integrator.py | 2 +- .../git/test_transform_jinja2_information.py | 142 ++++++++++++++++++ changelog/3094.fixed.md | 1 + 3 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 backend/tests/unit/git/test_transform_jinja2_information.py create mode 100644 changelog/3094.fixed.md diff --git a/backend/infrahub/git/integrator.py b/backend/infrahub/git/integrator.py index 659e5429bc6..4004b441352 100644 --- a/backend/infrahub/git/integrator.py +++ b/backend/infrahub/git/integrator.py @@ -563,7 +563,7 @@ async def compare_jinja2_transform( ) -> bool: if ( existing_transform.description.value != local_transform.description - or existing_transform.template_path.value != local_transform.template_path + or existing_transform.template_path.value != str(local_transform.template_path) or existing_transform.query.id != local_transform.query or existing_transform.dependencies.value != local_transform.dependencies or existing_transform.dependencies_complete.value != local_transform.dependencies_complete diff --git a/backend/tests/unit/git/test_transform_jinja2_information.py b/backend/tests/unit/git/test_transform_jinja2_information.py new file mode 100644 index 00000000000..a72f20f95a6 --- /dev/null +++ b/backend/tests/unit/git/test_transform_jinja2_information.py @@ -0,0 +1,142 @@ +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import pytest +from infrahub_sdk import Config, InfrahubClient +from infrahub_sdk.node import InfrahubNode +from infrahub_sdk.protocols import CoreTransformJinja2 +from infrahub_sdk.schema import NodeSchemaAPI + +from infrahub.core.schema import SchemaRoot, core_models, internal_schema +from infrahub.core.schema.schema_branch import SchemaBranch +from infrahub.git.integrator import InfrahubRepositoryIntegrator, InfrahubRepositoryJinja2 + + +def _load_core_node_schema(kind: str) -> NodeSchemaAPI: + schema_branch = SchemaBranch(cache={}, name="test") + schema_branch.load_schema(schema=SchemaRoot(**internal_schema)) + schema_branch.load_schema(schema=SchemaRoot(**core_models)) + # Flatten inherited attributes/relationships so the node schema carries the full field set. + schema_branch.process_inheritance() + return NodeSchemaAPI(**schema_branch.get(name=kind, duplicate=False).model_dump()) + + +TRANSFORM_JINJA2_SCHEMA = _load_core_node_schema("CoreTransformJinja2") +EXISTING_TRANSFORM_ID = "a0d4c22a-5f60-4bf9-a53f-f9a335420492" + + +def _make_existing_transform( + query_id: str = "query-id", + template_path: str = "templates/test.j2", + description: str | None = None, + dependencies: list[str] | None = None, + dependencies_complete: bool = False, +) -> CoreTransformJinja2: + client = InfrahubClient(config=Config(address="http://mock")) + data = { + "id": EXISTING_TRANSFORM_ID, + "__typename": "CoreTransformJinja2", + "display_label": "test-transform", + "name": {"value": "test", "__typename": "Text"}, + "label": {"value": "Test", "__typename": "Text"}, + "description": {"value": description, "__typename": "Text"}, + "template_path": {"value": template_path, "__typename": "Text"}, + "dependencies": {"value": dependencies if dependencies is not None else [], "__typename": "List"}, + "dependencies_complete": {"value": dependencies_complete, "__typename": "Boolean"}, + "query": { + "node": {"id": query_id, "display_label": "test-query", "__typename": "CoreGraphQLQuery"}, + "__typename": "NestedEdgedCoreGraphQLQuery", + }, + } + # Round-trip through the client store so the returned node is typed as the generated + # protocol rather than the bare InfrahubNode the constructor yields. + client.store.set(InfrahubNode(client=client, schema=TRANSFORM_JINJA2_SCHEMA, data=data)) + return client.store.get(kind=CoreTransformJinja2, key=EXISTING_TRANSFORM_ID) + + +def _make_local_transform( + template_path: str = "templates/test.j2", + description: str | None = None, + dependencies: list[str] | None = None, + dependencies_complete: bool = False, +) -> InfrahubRepositoryJinja2: + return InfrahubRepositoryJinja2( + name="test", + repository="repo-id", + query="query-id", + template_path=Path(template_path), + description=description, + dependencies=dependencies if dependencies is not None else [], + dependencies_complete=dependencies_complete, + ) + + +@dataclass +class CompareJinja2Case: + name: str + """Descriptive name for the test scenario (used as test ID).""" + + expected: bool + """Expected result of the comparison: True when the stored and local transform match.""" + + existing_kwargs: dict[str, Any] = field(default_factory=dict) + """Overrides for the stored transform node.""" + + local_kwargs: dict[str, Any] = field(default_factory=dict) + """Overrides for the local transform parsed from the repository config.""" + + +COMPARE_JINJA2_TEST_CASES: list[CompareJinja2Case] = [ + # Identical inputs: the local template_path is a Path while the stored value is a str, so an + # unchanged transform must compare equal across the type boundary rather than look changed. + CompareJinja2Case(name="identical", expected=True), + CompareJinja2Case( + name="template_path_changed", + expected=False, + existing_kwargs={"template_path": "templates/old.j2"}, + local_kwargs={"template_path": "templates/new.j2"}, + ), + CompareJinja2Case( + name="description_changed", + expected=False, + local_kwargs={"description": "New description"}, + ), + CompareJinja2Case( + name="description_removed", + expected=False, + existing_kwargs={"description": "Old description"}, + ), + CompareJinja2Case( + name="description_same", + expected=True, + existing_kwargs={"description": "Same"}, + local_kwargs={"description": "Same"}, + ), + CompareJinja2Case( + name="query_changed", + expected=False, + existing_kwargs={"query_id": "old-query-id"}, + ), + CompareJinja2Case( + name="dependencies_changed", + expected=False, + existing_kwargs={"dependencies": ["templates/test.j2"]}, + local_kwargs={"dependencies": ["templates/test.j2", "templates/partial.j2"]}, + ), + CompareJinja2Case( + name="dependencies_complete_changed", + expected=False, + local_kwargs={"dependencies_complete": True}, + ), +] + + +@pytest.mark.parametrize( + "test_case", + [pytest.param(tc, id=tc.name) for tc in COMPARE_JINJA2_TEST_CASES], +) +async def test_compare_jinja2_transform(test_case: CompareJinja2Case) -> None: + existing = _make_existing_transform(**test_case.existing_kwargs) + local = _make_local_transform(**test_case.local_kwargs) + assert await InfrahubRepositoryIntegrator.compare_jinja2_transform(existing, local) is test_case.expected diff --git a/changelog/3094.fixed.md b/changelog/3094.fixed.md new file mode 100644 index 00000000000..148711953df --- /dev/null +++ b/changelog/3094.fixed.md @@ -0,0 +1 @@ +Fixed issue where Jinja2 Transformations were always marked as being updated during repository imports even though there were no changes. From 07e549b5f45714f09a90eb6e05868a490e331245 Mon Sep 17 00:00:00 2001 From: Pol Michel <40861490+polmichel@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:45:16 +0200 Subject: [PATCH 62/75] fix: render Jinja2 computed attributes sourced from an unallocated number pool (closes #7836) (#10085) * test: add failing test for IFC-2095-jinja2-unallocated-pool Reproduces the Jinja2 computed-attribute crash when a macro formats a pool-sourced attribute that is not yet allocated during node creation. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: skip Jinja2 macros referencing unallocated pool attributes A computed Jinja2 attribute whose template reads a pool-sourced local attribute is now left unrendered while that attribute has no allocated value, instead of rendering against None. The macro is evaluated once the pool allocation has taken place. Co-Authored-By: Claude Opus 4.8 (1M context) * test: cover independent macros rendering when a pool is unallocated Adds a Node.new(process_pools=False) case asserting that a macro which does not reference the unallocated pool still renders, guarding against skipping every macro instead of only pool-dependent ones. Co-Authored-By: Claude Opus 4.8 (1M context) * chore: add changelog fragment for jinja2 pool fix Co-Authored-By: Claude Opus 4.8 (1M context) * test: reuse Snow schema helper for pool computed-attribute tests Derives the number-pool + Jinja2 computed-attribute schema from the shared Snow helper via deepcopy, overriding only the identifier template to exercise the leading-zero format filter, instead of defining a bespoke inline schema. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: guard recompute path against unallocated pool variables _resolve_jinja2_variables can now return None for a pool-sourced dependency that is not yet allocated; the recompute path skips that attribute instead of passing None into template rendering. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor: extract pending-pool-dependency check from variable resolver Keeps _resolve_jinja2_variables a pure variable-to-value mapping and moves the "can this macro be rendered yet" decision into a dedicated predicate, shared by the create and recompute paths. Co-Authored-By: Claude Opus 4.8 (1M context) * test: cover GraphQL create and mixed-variable pool macros Adds a GraphQL create path exercising the reporter's scenario end to end, and a macro referencing both the pool value and a plain attribute to confirm every variable renders once the pool is allocated. Co-Authored-By: Claude Opus 4.8 (1M context) * test: move GraphQL pool-macro test to the GraphQL mutation suite Relocates the GraphQL create test next to the other jinja2-computed- attribute mutation tests, and extracts the shared Snow schema builder and number-pool provisioning into tests/helpers/number_pool so both the core-layer and GraphQL-layer tests reuse them. Co-Authored-By: Claude Opus 4.8 (1M context) * test: build a fresh ComputedAttribute in the number-pool helper Assigns a new ComputedAttribute rather than mutating an optional field, so the helper type-checks under ty. Co-Authored-By: Claude Opus 4.8 (1M context) * chore: key changelog fragment to GitHub issue 7836 Renames the orphan changelog fragment so towncrier renders the link to the original issue this fix closes. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: defer computed attributes chained on a skipped pool macro When a computed attribute is skipped because it reads an unallocated pool value, any computed attribute that references it is now deferred as well, instead of rendering against the skipped value. Macros are already iterated in dependency order, so tracking the skipped set is enough to cascade the skip. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor: extract referenced-root-fields helper for macro dependencies Replaces the hand-rolled `variable.split("__")[0]` root extraction, which was repeated in both the create and recompute macro paths, with a single named helper. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor: move referenced-root-fields onto InfrahubJinja2Template The root-field extraction operates only on a template's variables, so it belongs on the template rather than as a static method on Node. Both the create and recompute macro paths call it through the template. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/infrahub/computed_attribute/jinja2.py | 8 ++ backend/infrahub/core/node/__init__.py | 49 +++++++--- .../node/test_create_node_computed_pool.py | 93 +++++++++++++++++++ .../test_mutation_create_jinja2_attributes.py | 35 +++++++ backend/tests/helpers/number_pool.py | 44 +++++++++ changelog/7836.fixed.md | 1 + 6 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 backend/tests/component/core/node/test_create_node_computed_pool.py create mode 100644 backend/tests/helpers/number_pool.py create mode 100644 changelog/7836.fixed.md diff --git a/backend/infrahub/computed_attribute/jinja2.py b/backend/infrahub/computed_attribute/jinja2.py index bf90884f4b0..ff6fbd15946 100644 --- a/backend/infrahub/computed_attribute/jinja2.py +++ b/backend/infrahub/computed_attribute/jinja2.py @@ -84,3 +84,11 @@ def __init__( filters={**FILTERS, **(filters or {})}, client=client, ) + + def get_referenced_root_fields(self) -> set[str]: + """Root schema field names the template depends on (the segment before ``__``). + + A variable such as ``owner__name__value`` resolves to the root field ``owner``; ``__`` is + the schema-path separator, so a field name never contains it. + """ + return {variable.split("__")[0] for variable in self.get_variables()} diff --git a/backend/infrahub/core/node/__init__.py b/backend/infrahub/core/node/__init__.py index b7a967fb7b5..054cc7dbfbf 100644 --- a/backend/infrahub/core/node/__init__.py +++ b/backend/infrahub/core/node/__init__.py @@ -77,6 +77,12 @@ def relationship_fields(self) -> dict[str, set[str]]: ... log = get_logger() +JINJA2_ALLOWED_PATH_TYPES = ( + SchemaElementPathType.ATTR_WITH_PROP + | SchemaElementPathType.REL_ONE_MANDATORY_ATTR_WITH_PROP + | SchemaElementPathType.REL_ONE_OPTIONAL_ATTR_WITH_PROP +) + class Node(BaseNode, MetadataInterface, metaclass=BaseNodeMeta): @classmethod @@ -706,6 +712,21 @@ async def _process_fields_attributes( return errors + def _has_pending_pool_dependency(self, schema_branch: SchemaBranch, jinja_template: InfrahubJinja2Template) -> bool: + """Whether the template reads a local pool-sourced attribute whose value is not allocated yet. + + Such a macro cannot be rendered until the pool allocation has taken place and must be skipped. + """ + for variable in jinja_template.get_variables(): + attribute_path = schema_branch.validate_schema_path( + node_schema=self._schema, path=variable, allowed_path_types=JINJA2_ALLOWED_PATH_TYPES + ) + if attribute_path.is_type_attribute: + attribute = self.get_attribute(attribute_path.active_attribute_schema.name) + if attribute.from_pool and attribute.value is None: + return True + return False + async def _resolve_jinja2_variables( self, db: InfrahubDatabase, @@ -713,16 +734,11 @@ async def _resolve_jinja2_variables( jinja_template: InfrahubJinja2Template, ) -> dict[str, Any]: """Resolve Jinja2 template variables from local attributes and relationship peers.""" - allowed_path_types = ( - SchemaElementPathType.ATTR_WITH_PROP - | SchemaElementPathType.REL_ONE_MANDATORY_ATTR_WITH_PROP - | SchemaElementPathType.REL_ONE_OPTIONAL_ATTR_WITH_PROP - ) variables: dict[str, Any] = {} for variable in jinja_template.get_variables(): attribute_path = schema_branch.validate_schema_path( - node_schema=self._schema, path=variable, allowed_path_types=allowed_path_types + node_schema=self._schema, path=variable, allowed_path_types=JINJA2_ALLOWED_PATH_TYPES ) if attribute_path.is_type_relationship: relationship = self.get_relationship(attribute_path.active_relationship_schema.name) @@ -740,6 +756,10 @@ async def _resolve_jinja2_variables( async def _process_macros(self, db: InfrahubDatabase) -> None: schema_branch = db.schema.get_schema_branch(self._branch.name) errors = [] + # Macros are iterated in dependency order, so a prerequisite is always seen before the + # macros that reference it. Skipping cascades: a macro whose dependency was skipped for an + # unallocated pool cannot render either and is deferred until the allocation happens. + skipped: set[str] = set() for macro in self._computed_jinja2_attributes: attr_schema = self._schema.get_attribute(name=macro) if not attr_schema.computed_attribute: @@ -754,10 +774,15 @@ async def _process_macros(self, db: InfrahubDatabase) -> None: continue jinja_template = InfrahubJinja2Template(template=attr_schema.computed_attribute.jinja2_template) + if jinja_template.get_referenced_root_fields() & skipped or self._has_pending_pool_dependency( + schema_branch=schema_branch, jinja_template=jinja_template + ): + skipped.add(macro) + continue + variables = await self._resolve_jinja2_variables( db=db, schema_branch=schema_branch, jinja_template=jinja_template ) - content = await jinja_template.render(variables=variables) generator_method_name = "_generate_attribute_default" @@ -809,18 +834,20 @@ async def _recompute_local_jinja2( jinja_template = InfrahubJinja2Template(template=attr_schema.computed_attribute.jinja2_template) - referenced_variables = jinja_template.get_variables() - depends_on_failed = any(var.split("__")[0] in failed_attributes for var in referenced_variables) - if depends_on_failed: + referenced_attributes = jinja_template.get_referenced_root_fields() + if failed_dependencies := referenced_attributes & failed_attributes: log.warning( "Skipping recomputation of Jinja2 attribute due to failed dependency", node_kind=self._schema.kind, attribute_name=target.attribute.name, - failed_dependencies=failed_attributes & {var.split("__")[0] for var in referenced_variables}, + failed_dependencies=failed_dependencies, ) failed_attributes.add(target.attribute.name) continue + if self._has_pending_pool_dependency(schema_branch=schema_branch, jinja_template=jinja_template): + continue + variables = await self._resolve_jinja2_variables( db=db, schema_branch=schema_branch, jinja_template=jinja_template ) diff --git a/backend/tests/component/core/node/test_create_node_computed_pool.py b/backend/tests/component/core/node/test_create_node_computed_pool.py new file mode 100644 index 00000000000..0fa66bcc9cd --- /dev/null +++ b/backend/tests/component/core/node/test_create_node_computed_pool.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from infrahub.core import registry +from infrahub.core.node import Node +from infrahub.core.node.create import create_node +from tests.helpers.number_pool import register_and_provision_number_pools, snow_schema_with_format_identifier +from tests.helpers.schema_builders import computed_jinja2_attr + +if TYPE_CHECKING: + from infrahub.core.branch import Branch + from infrahub.database import InfrahubDatabase + + +async def test_create_with_jinja2_format_filter_on_number_pool( + db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: None +) -> None: + """A Jinja2 macro that formats a pool-sourced attribute renders once the pool is allocated.""" + await register_and_provision_number_pools(db=db, branch=default_branch, schema=snow_schema_with_format_identifier()) + + incident_schema = registry.schema.get_node_schema(name="SnowIncident", branch=default_branch) + incident = await create_node( + data={"title": "The first issue"}, + db=db, + branch=default_branch, + schema=incident_schema, + ) + + assert incident.number.value == 1 + assert incident.identifier.value == "INC000000001" + + +async def test_new_with_unallocated_pool_renders_independent_macros( + db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: None +) -> None: + """With pools unallocated, a pool-dependent macro is skipped while other macros still render.""" + schema = snow_schema_with_format_identifier( + extra_incident_attrs=[computed_jinja2_attr(name="slug", template="{{ title__value | lower }}", unique=False)] + ) + await register_and_provision_number_pools(db=db, branch=default_branch, schema=schema) + + incident_schema = registry.schema.get_node_schema(name="SnowIncident", branch=default_branch) + node = await Node.init(db=db, schema=incident_schema, branch=default_branch) + await node.new(db=db, process_pools=False, title="First Issue") + + assert node.number.value is None + assert node.slug.value == "first issue" + + +async def test_create_with_jinja2_macro_mixing_pool_and_plain_attributes( + db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: None +) -> None: + """A macro referencing both a pool value and a plain attribute renders every variable after allocation.""" + schema = snow_schema_with_format_identifier( + identifier_template="INC{{ '%09d' | format(number__value) }}-{{ title__value | lower }}" + ) + await register_and_provision_number_pools(db=db, branch=default_branch, schema=schema) + + incident_schema = registry.schema.get_node_schema(name="SnowIncident", branch=default_branch) + incident = await create_node( + data={"title": "First Issue"}, + db=db, + branch=default_branch, + schema=incident_schema, + ) + + assert incident.number.value == 1 + assert incident.identifier.value == "INC000000001-first issue" + + +async def test_create_with_chained_macro_depending_on_pool_macro( + db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: None +) -> None: + """A computed attribute chained on a pool-dependent one renders once the pool is allocated.""" + schema = snow_schema_with_format_identifier( + extra_incident_attrs=[ + computed_jinja2_attr(name="reference", template="REF-{{ identifier__value }}", unique=False) + ] + ) + await register_and_provision_number_pools(db=db, branch=default_branch, schema=schema) + + incident_schema = registry.schema.get_node_schema(name="SnowIncident", branch=default_branch) + incident = await create_node( + data={"title": "First Issue"}, + db=db, + branch=default_branch, + schema=incident_schema, + ) + + assert incident.number.value == 1 + assert incident.identifier.value == "INC000000001" + assert incident.reference.value == "REF-INC000000001" diff --git a/backend/tests/component/graphql/test_mutation_create_jinja2_attributes.py b/backend/tests/component/graphql/test_mutation_create_jinja2_attributes.py index 02e0e7239aa..68ab2193163 100644 --- a/backend/tests/component/graphql/test_mutation_create_jinja2_attributes.py +++ b/backend/tests/component/graphql/test_mutation_create_jinja2_attributes.py @@ -6,8 +6,10 @@ from infrahub.core.node import Node from infrahub.core.schema import SchemaRoot, internal_schema from infrahub.graphql.initialization import prepare_graphql_params +from infrahub.graphql.registry import registry as graphql_registry from tests.constants import TestKind from tests.helpers.graphql import graphql +from tests.helpers.number_pool import register_and_provision_number_pools, snow_schema_with_format_identifier from tests.helpers.schema import CHILD, LOCATION_SCHEMA, THING, load_schema if TYPE_CHECKING: @@ -219,3 +221,36 @@ async def test_create_with_jinja2_with_generics( assert site_result.data assert site_result.data["TestingSiteCreate"]["ok"] is True assert site_result.data["TestingSiteCreate"]["object"]["code"]["value"] == "st" + + +async def test_create_with_jinja2_format_filter_on_number_pool( + db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: None +) -> None: + await register_and_provision_number_pools(db=db, branch=default_branch, schema=snow_schema_with_format_identifier()) + default_branch.update_schema_hash() + graphql_registry.clear_cache() + + query = """ + mutation { + SnowIncidentCreate(data: { title: { value: "The first issue" } }) { + ok + object { + id + identifier { value } + } + } + } + """ + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data + assert result.data["SnowIncidentCreate"]["ok"] is True + assert result.data["SnowIncidentCreate"]["object"]["identifier"]["value"] == "INC000000001" diff --git a/backend/tests/helpers/number_pool.py b/backend/tests/helpers/number_pool.py new file mode 100644 index 00000000000..4070d7f7b04 --- /dev/null +++ b/backend/tests/helpers/number_pool.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING + +from infrahub.core import registry +from infrahub.core.constants import ComputedAttributeKind, InfrahubKind +from infrahub.core.node.resource_manager.number_pool import CoreNumberPool +from infrahub.core.schema import SchemaRoot +from infrahub.core.schema.attribute_parameters import NumberPoolParameters +from infrahub.core.schema.computed_attribute import ComputedAttribute +from infrahub.pools.schema_number_pool_synchronizer import SchemaNumberPoolSynchronizer +from infrahub.pools.schema_number_pool_upserter import SchemaNumberPoolUpserter +from tests.helpers.schema.snow import SNOW_INCIDENT, SNOW_TASK + +if TYPE_CHECKING: + from infrahub.core.branch import Branch + from infrahub.core.schema import AttributeSchema + from infrahub.database import InfrahubDatabase + + +def snow_schema_with_format_identifier( + identifier_template: str = "INC{{ '%09d' | format(number__value) }}", + extra_incident_attrs: list[AttributeSchema] | None = None, +) -> SchemaRoot: + """Snow schema whose incident identifier formats the pool value with a leading-zero filter.""" + task = copy.deepcopy(SNOW_TASK) + task.get_attribute(name="number").parameters = NumberPoolParameters(start_range=1, end_range=1000) + incident = copy.deepcopy(SNOW_INCIDENT) + incident.get_attribute(name="identifier").computed_attribute = ComputedAttribute( + kind=ComputedAttributeKind.JINJA2, jinja2_template=identifier_template + ) + if extra_incident_attrs: + incident.attributes.extend(extra_incident_attrs) + return SchemaRoot(generics=[task], nodes=[incident]) + + +async def register_and_provision_number_pools(db: InfrahubDatabase, branch: Branch, schema: SchemaRoot) -> None: + """Register the schema and provision the number pools defined by its NumberPool attributes.""" + registry.schema.register_schema(schema=schema, branch=branch.name) + registry.node[InfrahubKind.NUMBERPOOL] = CoreNumberPool + upserter = SchemaNumberPoolUpserter(db=db, schema_manager=registry.schema) + synchronizer = SchemaNumberPoolSynchronizer(db=db, schema_manager=registry.schema, upserter=upserter) + await synchronizer.run() diff --git a/changelog/7836.fixed.md b/changelog/7836.fixed.md new file mode 100644 index 00000000000..18ab3b7650b --- /dev/null +++ b/changelog/7836.fixed.md @@ -0,0 +1 @@ +Fixed node creation failing when a Jinja2 computed attribute formatted a value sourced from a number pool; the computed attribute now renders once the pool value has been allocated. From d197ed922d2b97b4ed471c4fb3fc61b9531d3fcc Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:43:03 -0700 Subject: [PATCH 63/75] perf(graphql): skip peer hydration for id-only single relationships (#10062) * perf(graphql): skip peer hydration for id-only single relationships When a cardinality-one relationship query requests only the peer node's `id` (no properties, metadata, or additional node fields), return the peer ID already loaded on the parent instead of hydrating a full peer Node via NodeDataLoader. This avoids redundant database work on relationship-heavy queries such as cable-plan reports. The resolver falls through to the existing NodeDataLoader / query_peers paths whenever any other field, property, or metadata is requested, so behavior is unchanged for all other queries. * fix(graphql): skip id-only relationship shortcut for generic peers The id-only single-relationship shortcut returns {"id": peer_id} without the peer kind. For a relationship whose peer is a generic, the GraphQL node field is an interface whose resolve_type needs the kind, so it fails with "Unable to identify the type of the instance." Restrict the shortcut to concrete peers; generic peers fall back to full hydration. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_015ZdYyE4JewWbMZAbvWPjQe * perf(graphql): carry peer kind so generic id-only relationships skip hydration The id-only single-relationship shortcut had to be disabled for generic peers: it returned {"id": peer_id} without a kind, and a generic peer resolves to a GraphQL interface whose resolve_type indexes the type registry by kind. That excluded a large share of core-schema relationships from the optimization. The peer's concrete kind was available all along. The relationship query already binds the peer vertex and returns peer.uuid, so returning peer.kind alongside it costs no extra round trip. Thread that kind through GroupedPeerNodes, PeerWithRelationshipMetadata and Relationship so the preloaded stub on a node's GraphQL response can carry __kind__, and let the shortcut serve generic peers from it. The shortcut declines whenever the kind is absent and the resolver hydrates as before, so correctness never depends on the enrichment. Co-Authored-By: Claude Opus 5 (1M context) * test(graphql): assert generic-peer id-only relationship skips hydration Add a component test that exercises the id-only single-relationship shortcut against a cardinality-one relationship whose peer is a generic. It monkeypatches NodeDataLoader.load to raise, so a regression to the correct-but-slower hydrating path fails loudly instead of passing silently. * test(benchmark): add id-only single-relationship benchmark Add test_query_rel_one_id_only mirroring test_query_rel_one but selecting only the peer id (repository { node { id } }), so the id-only single-relationship shortcut is exercised and validated by CodSpeed CI. * update name, reduce comment * consolidate tests * reduce size of huge test file --------- Co-authored-by: Claude Co-authored-by: Aaron McCarty --- backend/infrahub/core/manager.py | 4 +- backend/infrahub/core/node/__init__.py | 18 +- backend/infrahub/core/query/node.py | 16 +- backend/infrahub/core/relationship/model.py | 11 + .../graphql/resolvers/single_relationship.py | 52 + backend/tests/benchmark/test_graphql_query.py | 52 + .../component/graphql/test_graphql_query.py | 1764 +++-------------- .../graphql/test_graphql_read_rel_query.py | 1303 ++++++++++++ ...e-relationship-id-only-shortcut.changed.md | 1 + 9 files changed, 1742 insertions(+), 1479 deletions(-) create mode 100644 backend/tests/component/graphql/test_graphql_read_rel_query.py create mode 100644 changelog/+single-relationship-id-only-shortcut.changed.md diff --git a/backend/infrahub/core/manager.py b/backend/infrahub/core/manager.py index 904dd6d71dd..591b0ff11f2 100644 --- a/backend/infrahub/core/manager.py +++ b/backend/infrahub/core/manager.py @@ -1328,7 +1328,9 @@ async def _enrich_one_node_with_relationships( direction=rel_schema.direction, peer_id=peer_id, ) - peer_with_metadata = PeerWithRelationshipMetadata(peer=peer) + peer_with_metadata = PeerWithRelationshipMetadata( + peer=peer, peer_kind=grouped_peer_nodes.get_peer_kind(peer_id=peer_id) + ) if not metadata_map: rel_peers_with_metadata.append(peer_with_metadata) continue diff --git a/backend/infrahub/core/node/__init__.py b/backend/infrahub/core/node/__init__.py index 054cc7dbfbf..d36677addfb 100644 --- a/backend/infrahub/core/node/__init__.py +++ b/backend/infrahub/core/node/__init__.py @@ -55,6 +55,7 @@ from infrahub.core.branch import Branch from infrahub.core.creation_context import NodeCreationContext + from infrahub.core.relationship import Relationship from infrahub.core.schema.schema_branch import SchemaBranch from infrahub.database import InfrahubDatabase @@ -84,6 +85,19 @@ def relationship_fields(self) -> dict[str, set[str]]: ... ) +def _build_peer_stub(relationship: Relationship) -> dict[str, Any]: + """Build the minimal peer payload preloaded on a node's GraphQL response. + + The concrete kind is included when known so that a consumer resolving an + abstract GraphQL type does not have to hydrate the peer to learn it. + """ + stub: dict[str, Any] = {"id": relationship.peer_id} + peer_kind = relationship.get_concrete_peer_kind() + if peer_kind: + stub[KIND_GRAPHQL_FIELD_NAME] = peer_kind + return stub + + class Node(BaseNode, MetadataInterface, metaclass=BaseNodeMeta): @classmethod def __init_subclass_with_meta__( @@ -1319,7 +1333,9 @@ async def to_graphql( peer_rels = list(rel_manager) if peer_rels: response[relationship_schema.name] = [ - {"node": {"id": relationship.peer_id}} for relationship in peer_rels if relationship.peer_id + {"node": _build_peer_stub(relationship=relationship)} + for relationship in peer_rels + if relationship.peer_id ] except LookupError: continue diff --git a/backend/infrahub/core/query/node.py b/backend/infrahub/core/query/node.py index 5e6e5576f3d..3e0bbe550a7 100644 --- a/backend/infrahub/core/query/node.py +++ b/backend/infrahub/core/query/node.py @@ -995,6 +995,7 @@ def __init__(self) -> None: self._metadata_map: dict[ tuple[str, str, RelationshipDirection, str], dict[MetadataOptions, Timestamp | str | bool | None] ] = {} + self._peer_kind_map: dict[str, str] = {} def add_peer( self, @@ -1002,6 +1003,7 @@ def add_peer( rel_name: str, peer_id: str, direction: RelationshipDirection, + peer_kind: str, created_at: Timestamp | None = None, created_by: str | None = None, updated_at: Timestamp | None = None, @@ -1014,6 +1016,7 @@ def add_peer( if direction not in self._rel_directions_map[node_id, rel_name]: self._rel_directions_map[node_id, rel_name][direction] = set() self._rel_directions_map[node_id, rel_name][direction].add(peer_id) + self._peer_kind_map[peer_id] = peer_kind key = (node_id, rel_name, direction, peer_id) provided = (created_at, created_by, updated_at, updated_by, source_id, owner_id, is_protected) if any(v is not None for v in provided): @@ -1053,6 +1056,9 @@ def get_metadata_map( ) -> dict[MetadataOptions, Timestamp | str | bool | None]: return self._metadata_map.get((node_id, rel_name, direction, peer_id), {}) + def get_peer_kind(self, peer_id: str) -> str: + return self._peer_kind_map[peer_id] + class NodeListGetRelationshipsQuery(Query): name: str = "node_list_get_relationship" @@ -1217,7 +1223,7 @@ async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG WHERE r2.status = "active" RETURN r1, r2 } - RETURN n.uuid AS n_uuid, rel, peer.uuid AS peer_uuid, "inbound" as direction, r1, r2 + RETURN n.uuid AS n_uuid, rel, peer.uuid AS peer_uuid, peer.kind AS peer_kind, "inbound" as direction, r1, r2 UNION WITH n MATCH (n)-[:IS_RELATED]->(rel:Relationship)-[:IS_RELATED]->(peer) @@ -1241,7 +1247,7 @@ async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG WHERE r2.status = "active" RETURN r1, r2 } - RETURN n.uuid AS n_uuid, rel, peer.uuid AS peer_uuid, "outbound" as direction, r1, r2 + RETURN n.uuid AS n_uuid, rel, peer.uuid AS peer_uuid, peer.kind AS peer_kind, "outbound" as direction, r1, r2 UNION WITH n MATCH (n)-[:IS_RELATED]->(rel:Relationship)<-[:IS_RELATED]-(peer) @@ -1265,13 +1271,13 @@ async def query_init(self, db: InfrahubDatabase, **kwargs) -> None: # noqa: ARG WHERE r2.status = "active" RETURN r1, r2 } - RETURN n.uuid AS n_uuid, rel, peer.uuid AS peer_uuid, "bidirectional" as direction, r1, r2 + RETURN n.uuid AS n_uuid, rel, peer.uuid AS peer_uuid, peer.kind AS peer_kind, "bidirectional" as direction, r1, r2 } """ % {"filters": rels_filter} self.add_to_query(query) self.order_by = ["n_uuid", "rel_name", "peer_uuid", "direction"] - self.return_labels = ["n_uuid", "peer_uuid", "direction"] + self.return_labels = ["n_uuid", "peer_uuid", "peer_kind", "direction"] self._add_created_metadata_to_query() self._add_updated_metadata_to_query(branch_filter_str=rels_filter) @@ -1288,6 +1294,7 @@ def get_peers_group_by_node(self) -> GroupedPeerNodes: node_id = result.get("n_uuid") rel_name = result.get("rel_name") peer_id = result.get("peer_uuid") + peer_kind = result.get_as_type("peer_kind", return_type=str) direction = str(result.get("direction")) created_at = None @@ -1330,6 +1337,7 @@ def get_peers_group_by_node(self) -> GroupedPeerNodes: rel_name=rel_name, peer_id=peer_id, direction=direction_enum, + peer_kind=peer_kind, created_at=created_at, created_by=created_by_str, updated_at=updated_at, diff --git a/backend/infrahub/core/relationship/model.py b/backend/infrahub/core/relationship/model.py index e8c2701df0b..8d11bb938f3 100644 --- a/backend/infrahub/core/relationship/model.py +++ b/backend/infrahub/core/relationship/model.py @@ -88,6 +88,7 @@ class RelationshipUpdateDetails: @dataclass class PeerWithRelationshipMetadata: peer: Node | str + peer_kind: str | None = None created_at: Timestamp | None = None created_by: str | None = None updated_at: Timestamp | None = None @@ -139,6 +140,7 @@ def __init__( self._peer: Node | str | None = None self.peer_id: str | None = None self.peer_hfid: list[str] | None = None + self._resolved_peer_kind: str | None = None self.data: dict | RelationshipPeerData | str | Node | None = None self.from_pool: dict[str, Any] | None = None @@ -175,6 +177,13 @@ def get_peer_kind(self) -> str: return self._peer.get_kind() + def get_concrete_peer_kind(self) -> str | None: + """Return the peer's concrete kind, or None when only the schema's (possibly generic) peer kind is known.""" + if self._peer and not isinstance(self._peer, str): + return self._peer.get_kind() + + return self._resolved_peer_kind + @property def node_id(self) -> str: if self._node_id: @@ -222,6 +231,7 @@ def _get_updated_by(self) -> str | None: def _process_relationship_peer_data(self, data: RelationshipPeerData) -> None: self.set_peer(value=str(data.peer_id)) + self._resolved_peer_kind = data.peer_kind if not self.id and data.rel_node_id: self.id = data.rel_node_id @@ -262,6 +272,7 @@ def _process_dict_data(self, data: dict) -> None: def _process_peer_with_relationship_metadata(self, data: PeerWithRelationshipMetadata) -> None: self.set_peer(value=data.peer) + self._resolved_peer_kind = data.peer_kind self._set_created_at(data.created_at) self._set_created_by(data.created_by) self._set_updated_at(data.updated_at) diff --git a/backend/infrahub/graphql/resolvers/single_relationship.py b/backend/infrahub/graphql/resolvers/single_relationship.py index b134b9a6386..a821d7aaf10 100644 --- a/backend/infrahub/graphql/resolvers/single_relationship.py +++ b/backend/infrahub/graphql/resolvers/single_relationship.py @@ -10,8 +10,10 @@ from infrahub.core.node import Node from infrahub.core.relationship import Relationship from infrahub.core.schema.relationship_schema import RelationshipSchema +from infrahub.core.schema.schema_branch import SchemaBranch from infrahub.core.timestamp import Timestamp from infrahub.database import InfrahubDatabase +from infrahub.graphql.constants import KIND_GRAPHQL_FIELD_NAME from infrahub.graphql.field_extractor import extract_graphql_fields from infrahub.graphql.metadata import build_metadata_query_options, get_metadata_options_from_fields @@ -28,6 +30,41 @@ class SingleRelationshipResolver: def __init__(self) -> None: self._data_loader_instances: dict[GetManyParams, NodeDataLoader] = {} + def _build_id_only_node( + self, + rel_schema: RelationshipSchema, + parent: dict[str, Any], + schema_branch: SchemaBranch, + node_fields: dict[str, Any], + property_fields: dict[str, Any], + metadata_fields: dict[str, Any], + ) -> dict[str, Any] | None: + """Build the peer payload for an id-only query straight from the parent's preloaded data. + + The cardinality-one peer ID is already loaded on the parent, so a query + wanting nothing but that ID can be answered without hydrating the peer. + Returns None whenever that is not possible and the caller has to hydrate. + """ + if set(node_fields) != {"id"} or property_fields or any(metadata_fields.values()): + return None + + try: + peer_stub = parent[rel_schema.name][0]["node"] + peer_id = peer_stub["id"] + except (KeyError, IndexError): + return None + + if rel_schema.peer not in schema_branch.generics: + return {"id": peer_id} + + # A generic peer resolves to a GraphQL interface, whose type resolution + # needs the peer's concrete kind. + peer_kind = peer_stub.get(KIND_GRAPHQL_FIELD_NAME) + if not peer_kind: + return None + + return {"id": peer_id, KIND_GRAPHQL_FIELD_NAME: peer_kind} + def _build_relationship_meta_response( self, relationship: Relationship, metadata_fields: dict[str, Any] ) -> dict[str, Any]: @@ -85,6 +122,21 @@ async def resolve(self, parent: dict, info: GraphQLResolveInfo, **kwargs: Any) - response: dict[str, Any] = {"node": None, "properties": {}} + schema_branch = graphql_context.db.schema.get_schema_branch(name=graphql_context.branch.name) + id_only_node = self._build_id_only_node( + rel_schema=node_rel, + parent=parent, + schema_branch=schema_branch, + node_fields=node_fields, + property_fields=property_fields, + metadata_fields=metadata_fields, + ) + if id_only_node is not None: + response["node"] = id_only_node + if graphql_context.related_node_ids is not None: + graphql_context.related_node_ids.add(id_only_node["id"]) + return response + relationship: Relationship | None = None peer_node: Node | None = None diff --git a/backend/tests/benchmark/test_graphql_query.py b/backend/tests/benchmark/test_graphql_query.py index ef7a52d7f55..7a1791aca8d 100644 --- a/backend/tests/benchmark/test_graphql_query.py +++ b/backend/tests/benchmark/test_graphql_query.py @@ -213,6 +213,58 @@ def test_query_rel_one( ) +def test_query_rel_one_id_only( + exec_async: Callable[..., Any], + aio_benchmark: Callable[..., Any], + db: InfrahubDatabase, + default_branch: Branch, + dataset04: None, +) -> None: + query = """ + query { + CoreGraphQLQuery { + count + edges { + node { + id + display_label + name { + value + } + repository { + node { + id + } + } + } + } + } + } + """ + + default_branch.update_schema_hash() + gql_params = exec_async(prepare_graphql_params, db=db, branch=default_branch) + + for _ in range(NBR_WARMUP): + exec_async( + graphql, + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + aio_benchmark( + graphql, + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + # @pytest.mark.xfail(reason="Disabling for now but it's not producing consistent results") # def test_query_rel_one_filter_rel_many(aio_benchmark, db: InfrahubDatabase, default_branch: Branch, dataset04): # query = """ diff --git a/backend/tests/component/graphql/test_graphql_query.py b/backend/tests/component/graphql/test_graphql_query.py index 7f916575405..ef09074be21 100644 --- a/backend/tests/component/graphql/test_graphql_query.py +++ b/backend/tests/component/graphql/test_graphql_query.py @@ -12,7 +12,7 @@ from infrahub.core.migrations.shared import MigrationInput from infrahub.core.node import Node from infrahub.core.path import SchemaPath -from infrahub.core.schema import NodeSchema, SchemaRoot +from infrahub.core.schema import NodeSchema from infrahub.core.schema.schema_branch import SchemaBranch from infrahub.core.timestamp import Timestamp from infrahub.database import InfrahubDatabase @@ -170,59 +170,6 @@ async def test_display_hfid(db: InfrahubDatabase, default_branch: Branch, animal } -async def test_display_hfid_related_node( - db: InfrahubDatabase, default_branch: Branch, animal_person_schema: SchemaBranch -) -> None: - person_schema = animal_person_schema.get_node(name="TestPerson") - dog_schema = animal_person_schema.get_node(name="TestDog") - - person1 = await Node.init(db=db, schema=person_schema, branch=default_branch) - await person1.new(db=db, name="Jack") - await person1.save(db=db) - - dog1 = await Node.init(db=db, schema=dog_schema, branch=default_branch) - await dog1.new(db=db, name="Rocky", breed="Labrador", owner=person1) - await dog1.save(db=db) - - query = """ - query { - TestPerson { - edges { - node { - hfid - animals { - edges { - node { - hfid - } - } - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert len(result.data["TestPerson"]["edges"]) == 1 - assert result.data["TestPerson"]["edges"][0] == { - "node": { - "animals": {"edges": [{"node": {"hfid": ["Jack", "Rocky"]}}]}, - "hfid": ["Jack"], - }, - } - - async def test_all_attributes( db: InfrahubDatabase, default_branch: Branch, data_schema: None, all_attribute_types_schema: NodeSchema ) -> None: @@ -311,7 +258,7 @@ async def test_all_attributes( assert results["obj2"]["prefix"]["prefixlen"] is None -async def test_nested_query(db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch) -> None: +async def test_query_typename(db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch) -> None: car = registry.schema.get_node_schema(name="TestCar") person = registry.schema.get_node_schema(name="TestPerson") @@ -335,17 +282,38 @@ async def test_nested_query(db: InfrahubDatabase, default_branch: Branch, car_pe query = """ query { TestPerson { + __typename edges { + __typename node { + __typename name { value + __typename } cars { + __typename edges { + __typename + properties { + __typename + } node { + __typename name { + __typename value } + owner { + __typename + node { + __typename + name { + value + __typename + } + } + } } } } @@ -354,7 +322,6 @@ async def test_nested_query(db: InfrahubDatabase, default_branch: Branch, car_pe } } """ - default_branch.update_schema_hash() gql_params = await prepare_graphql_params(db=db, branch=default_branch) result = await graphql( @@ -365,69 +332,48 @@ async def test_nested_query(db: InfrahubDatabase, default_branch: Branch, car_pe variable_values={}, ) + assert result.data assert result.errors is None - assert result.data result_per_name = {result["node"]["name"]["value"]: result["node"] for result in result.data["TestPerson"]["edges"]} assert sorted(result_per_name.keys()) == ["Jane", "John"] - assert len(result_per_name["John"]["cars"]["edges"]) == 2 - assert len(result_per_name["Jane"]["cars"]["edges"]) == 1 - assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, c3.id} - - -async def test_double_nested_query( - db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch -) -> None: - car = registry.schema.get_node_schema(name="TestCar") - person = registry.schema.get_node_schema(name="TestPerson") + assert result.data["TestPerson"]["__typename"] == "PaginatedTestPerson" + assert result.data["TestPerson"]["edges"][0]["__typename"] == "EdgedTestPerson" + assert result.data["TestPerson"]["edges"][0]["node"]["__typename"] == "TestPerson" + assert result.data["TestPerson"]["edges"][0]["node"]["name"]["__typename"] == "TextAttribute" + assert result_per_name["John"]["cars"]["edges"][0]["node"]["__typename"] == "TestCar" + assert result_per_name["John"]["cars"]["edges"][0]["node"]["owner"]["__typename"] == "NestedEdgedTestPerson" + assert result_per_name["John"]["cars"]["edges"][0]["node"]["owner"]["node"]["name"]["__typename"] == "TextAttribute" + assert result_per_name["John"]["cars"]["edges"][0]["properties"]["__typename"] == "RelationshipProperty" - p1 = await Node.init(db=db, schema=person) - await p1.new(db=db, name="John", height=180) - await p1.save(db=db) - p2 = await Node.init(db=db, schema=person) - await p2.new(db=db, name="Jane", height=170) - await p2.save(db=db) - c1 = await Node.init(db=db, schema=car) - await c1.new(db=db, name="volt", nbr_seats=4, is_electric=True, owner=p1) - await c1.save(db=db) - c2 = await Node.init(db=db, schema=car) - await c2.new(db=db, name="bolt", nbr_seats=4, is_electric=True, owner=p1) - await c2.save(db=db) - c3 = await Node.init(db=db, schema=car) - await c3.new(db=db, name="nolt", nbr_seats=4, is_electric=True, owner=p2) - await c3.save(db=db) +async def test_query_filter_ids(db: InfrahubDatabase, default_branch: Branch, criticality_schema: NodeSchema) -> None: + obj1 = await Node.init(db=db, schema=criticality_schema) + await obj1.new(db=db, name="low", level=4) + await obj1.save(db=db) + obj2 = await Node.init(db=db, schema=criticality_schema) + await obj2.new(db=db, name="medium", level=3, description="My desc", color="#333333") + await obj2.save(db=db) + obj3 = await Node.init(db=db, schema=criticality_schema) + await obj3.new(db=db, name="high", level=1, description="My desc", color="#222222") + await obj3.save(db=db) - query = """ + query = ( + """ query { - TestPerson { + TestCriticality(ids: ["%s"]) { edges { node { name { value } - cars { - count - edges { - node { - name { - value - } - owner { - node { - name { - value - } - } - } - } - } - } } } } } """ + % obj1.id + ) default_branch.update_schema_hash() gql_params = await prepare_graphql_params(db=db, branch=default_branch) result = await graphql( @@ -439,74 +385,12 @@ async def test_double_nested_query( ) assert result.errors is None - assert result.data - result_per_name = {result["node"]["name"]["value"]: result["node"] for result in result.data["TestPerson"]["edges"]} - assert sorted(result_per_name.keys()) == ["Jane", "John"] - assert len(result_per_name["John"]["cars"]["edges"]) == 2 - assert len(result_per_name["Jane"]["cars"]["edges"]) == 1 - assert result_per_name["John"]["cars"]["count"] == 2 - assert result_per_name["Jane"]["cars"]["count"] == 1 - assert result_per_name["John"]["cars"]["edges"][0]["node"]["owner"]["node"]["name"]["value"] == "John" - - assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, c3.id} - - -async def test_nested_query_single_relationship( - db: InfrahubDatabase, default_branch: Branch, node_group_schema: None, data_schema: None -) -> None: - raw_schema = { - "version": "1.0", - "generics": [ - { - "name": "Generic", - "namespace": "Location", - "hierarchical": True, - "attributes": [{"name": "name", "optional": False, "kind": "Text"}], - "relationships": [{"name": "devices", "peer": "InfraDevice", "cardinality": "many", "optional": True}], - } - ], - "nodes": [ - { - "name": "Device", - "namespace": "Infra", - "attributes": [{"name": "name", "kind": "Text", "optional": False}], - "relationships": [ - {"name": "location", "peer": "LocationGeneric", "optional": False, "cardinality": "one"} - ], - }, - { - "name": "Site", - "namespace": "Location", - "inherit_from": ["LocationGeneric"], - "attributes": [{"name": "description", "optional": False, "kind": "Text"}], - }, - ], - } - schema = SchemaRoot(**raw_schema) - schema_branch = registry.schema.register_schema(schema=schema, branch=default_branch.name) - - site_schema = schema_branch.get_node(name="LocationSite") - device_schema = schema_branch.get_node(name="InfraDevice") - - site1 = await Node.init(db=db, schema=site_schema, branch=default_branch) - await site1.new(db=db, name="site1", description="test") - await site1.save(db=db) - - device1 = await Node.init(db=db, schema=device_schema, branch=default_branch) - await device1.new(db=db, name="device1", location=site1) - await device1.save(db=db) - - device2 = await Node.init(db=db, schema=device_schema, branch=default_branch) - await device2.new(db=db, name="device2", location=site1) - await device2.save(db=db) + assert len(result.data["TestCriticality"]["edges"]) == 1 query = """ - fragment LocationData on LocationSite { - name { - value - } - devices { + query { + TestCriticality(ids: ["%s", "%s"]) { edges { node { name { @@ -516,19 +400,41 @@ async def test_nested_query_single_relationship( } } } + """ % ( + obj1.id, + obj2.id, + ) + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data + assert len(result.data["TestCriticality"]["edges"]) == 2 + +async def test_query_filter_relationship_isnull( + db: InfrahubDatabase, + default_branch: Branch, + person_albert_main: Node, + person_john_main: Node, + person_jane_main: Node, + car_camry_main: Node, + car_accord_main: Node, +) -> None: + query = """ query { - InfraDevice { + TestPerson(cars__isnull: true) { + count edges { node { - name { - value - } - location { - node { - ... LocationData - } - } + id } } } @@ -546,96 +452,59 @@ async def test_nested_query_single_relationship( assert result.errors is None assert result.data - result_per_name = { - result["node"]["name"]["value"]: result["node"] for result in result.data["InfraDevice"]["edges"] - } - assert sorted(result_per_name.keys()) == ["device1", "device2"] - expected_location_data = { - "node": { - "name": {"value": "site1"}, - "devices": {"edges": [{"node": {"name": {"value": "device1"}}}, {"node": {"name": {"value": "device2"}}}]}, - } - } - assert result.data["InfraDevice"]["edges"][0]["node"]["location"] == expected_location_data - assert result.data["InfraDevice"]["edges"][1]["node"]["location"] == expected_location_data - + assert result.data["TestPerson"]["count"] == 1 + assert len(result.data["TestPerson"]["edges"]) == 1 + assert result.data["TestPerson"]["edges"][0]["node"]["id"] == person_albert_main.id -async def test_nested_generic_query_many_relationship( - db: InfrahubDatabase, default_branch: Branch, node_group_schema: None, data_schema: None -) -> None: - """Validates that nested GraphQL fragments work for cardinality=many relationships.""" - raw_schema = { - "version": "1.0", - "generics": [ - { - "name": "Generic", - "namespace": "Location", - "hierarchical": True, - "attributes": [{"name": "name", "optional": False, "kind": "Text"}], - "relationships": [{"name": "devices", "peer": "InfraDevice", "cardinality": "many", "optional": True}], + query = """ + query { + TestPerson(cars__isnull: false) { + count + edges { + node { + id + } } - ], - "nodes": [ - { - "name": "Device", - "namespace": "Infra", - "attributes": [{"name": "name", "kind": "Text", "optional": False}], - "relationships": [ - {"name": "location", "peer": "LocationGeneric", "optional": False, "cardinality": "one"} - ], - }, - { - "name": "Site", - "namespace": "Location", - "inherit_from": ["LocationGeneric"], - "attributes": [{"name": "description", "optional": False, "kind": "Text"}], - }, - ], + } } - schema = SchemaRoot(**raw_schema) - schema_branch = registry.schema.register_schema(schema=schema, branch=default_branch.name) - - site_schema = schema_branch.get_node(name="LocationSite") - device_schema = schema_branch.get_node(name="InfraDevice") + """ + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) - site1 = await Node.init(db=db, schema=site_schema, branch=default_branch) - await site1.new(db=db, name="site1", description="test") - await site1.save(db=db) + assert result.errors is None + assert result.data + assert result.data["TestPerson"]["count"] == 2 + assert len(result.data["TestPerson"]["edges"]) == 2 + result_person_ids = {node["node"]["id"] for node in result.data["TestPerson"]["edges"]} + assert result_person_ids == {person_john_main.id, person_jane_main.id} - device1 = await Node.init(db=db, schema=device_schema, branch=default_branch) - await device1.new(db=db, name="device1", location=site1) - await device1.save(db=db) - device2 = await Node.init(db=db, schema=device_schema, branch=default_branch) - await device2.new(db=db, name="device2", location=site1) - await device2.save(db=db) +async def test_query_filter_attribute_isnull( + db: InfrahubDatabase, + default_branch: Branch, + person_albert_main: Node, + person_john_main: Node, + person_jane_main: Node, + car_camry_main: Node, + car_accord_main: Node, +) -> None: + person_albert = await NodeManager.get_one(db=db, id=person_albert_main.id) + person_albert.height.value = None + await person_albert.save(db=db) query = """ - fragment DeviceData on InfraDevice { - name { - value - } - } - - fragment LocationData on LocationSite { - name { - value - } - devices { - edges { - node { - ...DeviceData - } - } - } - } - query { - LocationSite { + TestPerson(height__isnull: true) { + count edges { - node { - ...LocationData - } + node { + id + } } } } @@ -651,92 +520,23 @@ async def test_nested_generic_query_many_relationship( ) assert result.errors is None - - assert result.data == { - "LocationSite": { - "edges": [ - { - "node": { - "name": {"value": "site1"}, - "devices": { - "edges": [ - {"node": {"name": {"value": "device1"}}}, - {"node": {"name": {"value": "device2"}}}, - ] - }, - } - } - ] - } - } - - -async def test_query_typename(db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch) -> None: - car = registry.schema.get_node_schema(name="TestCar") - person = registry.schema.get_node_schema(name="TestPerson") - - p1 = await Node.init(db=db, schema=person) - await p1.new(db=db, name="John", height=180) - await p1.save(db=db) - p2 = await Node.init(db=db, schema=person) - await p2.new(db=db, name="Jane", height=170) - await p2.save(db=db) - - c1 = await Node.init(db=db, schema=car) - await c1.new(db=db, name="volt", nbr_seats=4, is_electric=True, owner=p1) - await c1.save(db=db) - c2 = await Node.init(db=db, schema=car) - await c2.new(db=db, name="bolt", nbr_seats=4, is_electric=True, owner=p1) - await c2.save(db=db) - c3 = await Node.init(db=db, schema=car) - await c3.new(db=db, name="nolt", nbr_seats=4, is_electric=True, owner=p2) - await c3.save(db=db) + assert result.data + assert result.data["TestPerson"]["count"] == 1 + assert len(result.data["TestPerson"]["edges"]) == 1 + assert result.data["TestPerson"]["edges"][0]["node"]["id"] == person_albert_main.id query = """ query { - TestPerson { - __typename + TestPerson(height__isnull: false) { + count edges { - __typename node { - __typename - name { - value - __typename - } - cars { - __typename - edges { - __typename - properties { - __typename - } - node { - __typename - name { - __typename - value - } - owner { - __typename - node { - __typename - name { - value - __typename - } - } - } - } - } - } + id } } } } """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) result = await graphql( schema=gql_params.schema, source=query, @@ -745,36 +545,27 @@ async def test_query_typename(db: InfrahubDatabase, default_branch: Branch, car_ variable_values={}, ) - assert result.data assert result.errors is None - - result_per_name = {result["node"]["name"]["value"]: result["node"] for result in result.data["TestPerson"]["edges"]} - assert sorted(result_per_name.keys()) == ["Jane", "John"] - assert result.data["TestPerson"]["__typename"] == "PaginatedTestPerson" - assert result.data["TestPerson"]["edges"][0]["__typename"] == "EdgedTestPerson" - assert result.data["TestPerson"]["edges"][0]["node"]["__typename"] == "TestPerson" - assert result.data["TestPerson"]["edges"][0]["node"]["name"]["__typename"] == "TextAttribute" - assert result_per_name["John"]["cars"]["edges"][0]["node"]["__typename"] == "TestCar" - assert result_per_name["John"]["cars"]["edges"][0]["node"]["owner"]["__typename"] == "NestedEdgedTestPerson" - assert result_per_name["John"]["cars"]["edges"][0]["node"]["owner"]["node"]["name"]["__typename"] == "TextAttribute" - assert result_per_name["John"]["cars"]["edges"][0]["properties"]["__typename"] == "RelationshipProperty" + assert result.data + assert result.data["TestPerson"]["count"] == 2 + assert len(result.data["TestPerson"]["edges"]) == 2 + result_person_ids = {node["node"]["id"] for node in result.data["TestPerson"]["edges"]} + assert result_person_ids == {person_john_main.id, person_jane_main.id} -async def test_query_filter_ids(db: InfrahubDatabase, default_branch: Branch, criticality_schema: NodeSchema) -> None: +async def test_query_filter_local_attrs( + db: InfrahubDatabase, default_branch: Branch, criticality_schema: NodeSchema +) -> None: obj1 = await Node.init(db=db, schema=criticality_schema) await obj1.new(db=db, name="low", level=4) await obj1.save(db=db) obj2 = await Node.init(db=db, schema=criticality_schema) await obj2.new(db=db, name="medium", level=3, description="My desc", color="#333333") await obj2.save(db=db) - obj3 = await Node.init(db=db, schema=criticality_schema) - await obj3.new(db=db, name="high", level=1, description="My desc", color="#222222") - await obj3.save(db=db) - query = ( - """ + query = """ query { - TestCriticality(ids: ["%s"]) { + TestCriticality(name__value: "low") { edges { node { name { @@ -785,8 +576,6 @@ async def test_query_filter_ids(db: InfrahubDatabase, default_branch: Branch, cr } } """ - % obj1.id - ) default_branch.update_schema_hash() gql_params = await prepare_graphql_params(db=db, branch=default_branch) result = await graphql( @@ -801,9 +590,27 @@ async def test_query_filter_ids(db: InfrahubDatabase, default_branch: Branch, cr assert result.data assert len(result.data["TestCriticality"]["edges"]) == 1 + +@pytest.mark.parametrize("graphql_enums_on,enum_value", [(True, "MANUAL"), (False, '"manual"')]) +async def test_query_filter_on_enum( + db: InfrahubDatabase, + default_branch: Branch, + person_john_main: Node, + car_person_schema: SchemaBranch, + graphql_enums_on: bool, + enum_value: Literal["MANUAL", '"manual"'], + reset_graphql_schema_between_tests: None, +) -> None: + config.SETTINGS.experimental_features.graphql_enums = graphql_enums_on + car = registry.schema.get(name="TestCar") + + c1 = await Node.init(db=db, schema=car) + await c1.new(db=db, name="GoKart", nbr_seats=1, is_electric=True, owner=person_john_main, transmission="manual") + await c1.save(db=db) + query = """ query { - TestCriticality(ids: ["%s", "%s"]) { + TestCar(transmission__value: %s) { edges { node { name { @@ -813,10 +620,7 @@ async def test_query_filter_ids(db: InfrahubDatabase, default_branch: Branch, cr } } } - """ % ( - obj1.id, - obj2.id, - ) + """ % (enum_value) default_branch.update_schema_hash() gql_params = await prepare_graphql_params(db=db, branch=default_branch) result = await graphql( @@ -829,25 +633,50 @@ async def test_query_filter_ids(db: InfrahubDatabase, default_branch: Branch, cr assert result.errors is None assert result.data - assert len(result.data["TestCriticality"]["edges"]) == 2 + assert len(result.data["TestCar"]["edges"]) == 1 + assert result.data["TestCar"]["edges"][0]["node"]["name"]["value"] == "GoKart" -async def test_query_filter_relationship_isnull( - db: InfrahubDatabase, - default_branch: Branch, - person_albert_main: Node, - person_john_main: Node, - person_jane_main: Node, - car_camry_main: Node, - car_accord_main: Node, +async def test_query_multiple_filters( + db: InfrahubDatabase, default_branch: Branch, car_person_manufacturer_schema: None ) -> None: - query = """ + car = registry.schema.get(name="TestCar") + person = registry.schema.get(name="TestPerson") + manufacturer = registry.schema.get(name="TestManufacturer") + + p1 = await Node.init(db=db, schema=person) + await p1.new(db=db, name="John", height=180) + await p1.save(db=db) + p2 = await Node.init(db=db, schema=person) + await p2.new(db=db, name="Jane", height=170) + await p2.save(db=db) + + m1 = await Node.init(db=db, schema=manufacturer) + await m1.new(db=db, name="chevrolet") + await m1.save(db=db) + m2 = await Node.init(db=db, schema=manufacturer) + await m2.new(db=db, name="ford", description="from Michigan") + await m2.save(db=db) + + c1 = await Node.init(db=db, schema=car) + await c1.new(db=db, name="volt", nbr_seats=4, is_electric=False, owner=p1, manufacturer=m1) + await c1.save(db=db) + c2 = await Node.init(db=db, schema=car) + await c2.new(db=db, name="bolt", nbr_seats=3, is_electric=True, owner=p1, manufacturer=m2) + await c2.save(db=db) + c3 = await Node.init(db=db, schema=car) + await c3.new(db=db, name="nolt", nbr_seats=4, is_electric=True, owner=p2, manufacturer=m1) + await c3.save(db=db) + + query01 = """ query { - TestPerson(cars__isnull: true) { - count + TestCar(owner__name__value: "John", nbr_seats__value: 4) { edges { node { id + name { + value + } } } } @@ -857,7 +686,7 @@ async def test_query_filter_relationship_isnull( gql_params = await prepare_graphql_params(db=db, branch=default_branch) result = await graphql( schema=gql_params.schema, - source=query, + source=query01, context_value=gql_params.context, root_value=None, variable_values={}, @@ -865,25 +694,28 @@ async def test_query_filter_relationship_isnull( assert result.errors is None assert result.data - assert result.data["TestPerson"]["count"] == 1 - assert len(result.data["TestPerson"]["edges"]) == 1 - assert result.data["TestPerson"]["edges"][0]["node"]["id"] == person_albert_main.id + assert len(result.data["TestCar"]["edges"]) == 1 + assert result.data["TestCar"]["edges"][0]["node"]["id"] == c1.id - query = """ + query02 = """ query { - TestPerson(cars__isnull: false) { - count + TestCar(is_electric__value: true, nbr_seats__value: 4) { edges { node { id + name { + value + } } } } } """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) result = await graphql( schema=gql_params.schema, - source=query, + source=query02, context_value=gql_params.context, root_value=None, variable_values={}, @@ -891,32 +723,18 @@ async def test_query_filter_relationship_isnull( assert result.errors is None assert result.data - assert result.data["TestPerson"]["count"] == 2 - assert len(result.data["TestPerson"]["edges"]) == 2 - result_person_ids = {node["node"]["id"] for node in result.data["TestPerson"]["edges"]} - assert result_person_ids == {person_john_main.id, person_jane_main.id} - - -async def test_query_filter_attribute_isnull( - db: InfrahubDatabase, - default_branch: Branch, - person_albert_main: Node, - person_john_main: Node, - person_jane_main: Node, - car_camry_main: Node, - car_accord_main: Node, -) -> None: - person_albert = await NodeManager.get_one(db=db, id=person_albert_main.id) - person_albert.height.value = None - await person_albert.save(db=db) + assert len(result.data["TestCar"]["edges"]) == 1 + assert result.data["TestCar"]["edges"][0]["node"]["id"] == c3.id - query = """ + query03 = """ query { - TestPerson(height__isnull: true) { - count + TestCar(owner__name__value: "John", manufacturer__name__value: "ford") { edges { node { id + name { + value + } } } } @@ -926,7 +744,7 @@ async def test_query_filter_attribute_isnull( gql_params = await prepare_graphql_params(db=db, branch=default_branch) result = await graphql( schema=gql_params.schema, - source=query, + source=query03, context_value=gql_params.context, root_value=None, variable_values={}, @@ -934,25 +752,31 @@ async def test_query_filter_attribute_isnull( assert result.errors is None assert result.data - assert result.data["TestPerson"]["count"] == 1 - assert len(result.data["TestPerson"]["edges"]) == 1 - assert result.data["TestPerson"]["edges"][0]["node"]["id"] == person_albert_main.id + assert len(result.data["TestCar"]["edges"]) == 1 + assert result.data["TestCar"]["edges"][0]["node"]["id"] == c2.id - query = """ + query04 = """ query { - TestPerson(height__isnull: false) { - count + TestCar(owner__ids: ["%s"], manufacturer__ids: ["%s"]) { edges { node { id + name { + value + } } } } } - """ + """ % ( + p1.id, + m2.id, + ) + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) result = await graphql( schema=gql_params.schema, - source=query, + source=query04, context_value=gql_params.context, root_value=None, variable_values={}, @@ -960,245 +784,8 @@ async def test_query_filter_attribute_isnull( assert result.errors is None assert result.data - assert result.data["TestPerson"]["count"] == 2 - assert len(result.data["TestPerson"]["edges"]) == 2 - result_person_ids = {node["node"]["id"] for node in result.data["TestPerson"]["edges"]} - assert result_person_ids == {person_john_main.id, person_jane_main.id} - - -async def test_query_filter_local_attrs( - db: InfrahubDatabase, default_branch: Branch, criticality_schema: NodeSchema -) -> None: - obj1 = await Node.init(db=db, schema=criticality_schema) - await obj1.new(db=db, name="low", level=4) - await obj1.save(db=db) - obj2 = await Node.init(db=db, schema=criticality_schema) - await obj2.new(db=db, name="medium", level=3, description="My desc", color="#333333") - await obj2.save(db=db) - - query = """ - query { - TestCriticality(name__value: "low") { - edges { - node { - name { - value - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert len(result.data["TestCriticality"]["edges"]) == 1 - - -@pytest.mark.parametrize("graphql_enums_on,enum_value", [(True, "MANUAL"), (False, '"manual"')]) -async def test_query_filter_on_enum( - db: InfrahubDatabase, - default_branch: Branch, - person_john_main: Node, - car_person_schema: SchemaBranch, - graphql_enums_on: bool, - enum_value: Literal["MANUAL", '"manual"'], - reset_graphql_schema_between_tests: None, -) -> None: - config.SETTINGS.experimental_features.graphql_enums = graphql_enums_on - car = registry.schema.get(name="TestCar") - - c1 = await Node.init(db=db, schema=car) - await c1.new(db=db, name="GoKart", nbr_seats=1, is_electric=True, owner=person_john_main, transmission="manual") - await c1.save(db=db) - - query = """ - query { - TestCar(transmission__value: %s) { - edges { - node { - name { - value - } - } - } - } - } - """ % (enum_value) - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert len(result.data["TestCar"]["edges"]) == 1 - assert result.data["TestCar"]["edges"][0]["node"]["name"]["value"] == "GoKart" - - -async def test_query_multiple_filters( - db: InfrahubDatabase, default_branch: Branch, car_person_manufacturer_schema: None -) -> None: - car = registry.schema.get(name="TestCar") - person = registry.schema.get(name="TestPerson") - manufacturer = registry.schema.get(name="TestManufacturer") - - p1 = await Node.init(db=db, schema=person) - await p1.new(db=db, name="John", height=180) - await p1.save(db=db) - p2 = await Node.init(db=db, schema=person) - await p2.new(db=db, name="Jane", height=170) - await p2.save(db=db) - - m1 = await Node.init(db=db, schema=manufacturer) - await m1.new(db=db, name="chevrolet") - await m1.save(db=db) - m2 = await Node.init(db=db, schema=manufacturer) - await m2.new(db=db, name="ford", description="from Michigan") - await m2.save(db=db) - - c1 = await Node.init(db=db, schema=car) - await c1.new(db=db, name="volt", nbr_seats=4, is_electric=False, owner=p1, manufacturer=m1) - await c1.save(db=db) - c2 = await Node.init(db=db, schema=car) - await c2.new(db=db, name="bolt", nbr_seats=3, is_electric=True, owner=p1, manufacturer=m2) - await c2.save(db=db) - c3 = await Node.init(db=db, schema=car) - await c3.new(db=db, name="nolt", nbr_seats=4, is_electric=True, owner=p2, manufacturer=m1) - await c3.save(db=db) - - query01 = """ - query { - TestCar(owner__name__value: "John", nbr_seats__value: 4) { - edges { - node { - id - name { - value - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query01, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert len(result.data["TestCar"]["edges"]) == 1 - assert result.data["TestCar"]["edges"][0]["node"]["id"] == c1.id - - query02 = """ - query { - TestCar(is_electric__value: true, nbr_seats__value: 4) { - edges { - node { - id - name { - value - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query02, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert len(result.data["TestCar"]["edges"]) == 1 - assert result.data["TestCar"]["edges"][0]["node"]["id"] == c3.id - - query03 = """ - query { - TestCar(owner__name__value: "John", manufacturer__name__value: "ford") { - edges { - node { - id - name { - value - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query03, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert len(result.data["TestCar"]["edges"]) == 1 - assert result.data["TestCar"]["edges"][0]["node"]["id"] == c2.id - - query04 = """ - query { - TestCar(owner__ids: ["%s"], manufacturer__ids: ["%s"]) { - edges { - node { - id - name { - value - } - } - } - } - } - """ % ( - p1.id, - m2.id, - ) - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query04, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert len(result.data["TestCar"]["edges"]) == 1 - assert result.data["TestCar"]["edges"][0]["node"]["id"] == c2.id + assert len(result.data["TestCar"]["edges"]) == 1 + assert result.data["TestCar"]["edges"][0]["node"]["id"] == c2.id # test filter by peer ID after node kind migration person_schema = registry.schema.get("TestPerson", branch=default_branch) @@ -1618,130 +1205,18 @@ async def test_query_attribute_multiple_values( assert result.data["TestPerson"]["count"] == 2 -async def test_query_relationship_multiple_values( - db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch -) -> None: - car = registry.schema.get(name="TestCar") - person = registry.schema.get(name="TestPerson") +async def test_query_at_specific_time(db: InfrahubDatabase, default_branch: Branch, person_tag_schema: None) -> None: + t1 = await Node.init(db=db, schema="TestingTag") + await t1.new(db=db, name="Blue", description="The Blue tag") + await t1.save(db=db) + t2 = await Node.init(db=db, schema="TestingTag") + await t2.new(db=db, name="Red") + await t2.save(db=db) - p1 = await Node.init(db=db, schema=person) - await p1.new(db=db, name="John", height=180) - await p1.save(db=db) - p2 = await Node.init(db=db, schema=person) + time1 = Timestamp() - await p2.new(db=db, name="Jane", height=170) - await p2.save(db=db) - - c1 = await Node.init(db=db, schema=car) - await c1.new(db=db, name="volt", nbr_seats=4, is_electric=True, owner=p1) - await c1.save(db=db) - c2 = await Node.init(db=db, schema=car) - await c2.new(db=db, name="bolt", nbr_seats=4, is_electric=True, owner=p1) - await c2.save(db=db) - c3 = await Node.init(db=db, schema=car) - await c3.new(db=db, name="nolt", nbr_seats=4, is_electric=True, owner=p2) - await c3.save(db=db) - c4 = await Node.init(db=db, schema=car) - await c4.new(db=db, name="yaris", nbr_seats=5, is_electric=False, owner=p1) - await c4.save(db=db) - - query = """ - query { - TestPerson { - edges { - node { - name { - value - } - cars (name__values: ["volt", "nolt"]) { - edges { - node { - name { - value - } - } - } - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert len(result.data["TestPerson"]["edges"]) == 2 - assert result.data["TestPerson"]["edges"][0]["node"]["cars"]["edges"][0]["node"]["name"]["value"] == "volt" - assert result.data["TestPerson"]["edges"][1]["node"]["cars"]["edges"][0]["node"]["name"]["value"] == "nolt" - - -async def test_query_oneway_relationship(db: InfrahubDatabase, default_branch: Branch, person_tag_schema: None) -> None: - t1 = await Node.init(db=db, schema=InfrahubKind.TAG) - await t1.new(db=db, name="Blue", description="The Blue tag") - await t1.save(db=db) - t2 = await Node.init(db=db, schema=InfrahubKind.TAG) - await t2.new(db=db, name="Red") - await t2.save(db=db) - p1 = await Node.init(db=db, schema="TestPerson") - await p1.new(db=db, firstname="John", lastname="Doe", tags=[t1, t2]) - await p1.save(db=db) - - query = """ - query { - TestPerson { - edges { - node { - id - tags { - edges { - node { - name { - value - } - } - } - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert len(result.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"]) == 2 - - -async def test_query_at_specific_time(db: InfrahubDatabase, default_branch: Branch, person_tag_schema: None) -> None: - t1 = await Node.init(db=db, schema="TestingTag") - await t1.new(db=db, name="Blue", description="The Blue tag") - await t1.save(db=db) - t2 = await Node.init(db=db, schema="TestingTag") - await t2.new(db=db, name="Red") - await t2.save(db=db) - - time1 = Timestamp() - - t2.name.value = "Green" - await t2.save(db=db) + t2.name.value = "Green" + await t2.save(db=db) query = """ query { @@ -1934,85 +1409,6 @@ async def test_query_node_updated_at(db: InfrahubDatabase, default_branch: Branc # TODO IFC-1813 add test for cardinality-one updated_at -async def test_query_relationship_updated_at( - db: InfrahubDatabase, default_branch: Branch, person_tag_schema: None -) -> None: - t1 = await Node.init(db=db, schema=InfrahubKind.TAG) - await t1.new(db=db, name="Blue", description="The Blue tag") - await t1.save(db=db) - t2 = await Node.init(db=db, schema=InfrahubKind.TAG) - await t2.new(db=db, name="Red") - await t2.save(db=db) - - query = """ - query { - TestPerson { - edges { - node { - id - tags { - edges { - node_metadata { - updated_at - } - node { - name { - value - } - } - properties { - updated_at - } - } - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result1 = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result1.errors is None - assert result1.data - assert result1.data["TestPerson"]["edges"] == [] - - p1 = await Node.init(db=db, schema="TestPerson") - await p1.new(db=db, firstname="John", lastname="Doe", tags=[t1, t2]) - await p1.save(db=db) - - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result2 = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result2.errors is None - assert result2.data - assert len(result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"]) == 2 - assert result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["node_metadata"]["updated_at"] is not None - assert ( - result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["node_metadata"]["updated_at"] - != result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["properties"]["updated_at"] - ) - assert result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["node_metadata"][ - "updated_at" - ] == Timestamp( - result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["node_metadata"]["updated_at"] - ).to_string(with_z=False) - - async def test_query_attribute_node_property_source( db: InfrahubDatabase, default_branch: Branch, @@ -2079,421 +1475,93 @@ async def test_query_attribute_node_property_owner( owner={"id": p1}, ) await c1.save(db=db) - - # test node-level query - query = """ - query { - TestPerson { - edges { - node { - id - name { - value - owner { - id - display_label - } - is_from_profile - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result1 = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result1.errors is None - assert result1.data - assert result1.data["TestPerson"]["edges"][0]["node"]["name"]["owner"] - assert result1.data["TestPerson"]["edges"][0]["node"]["name"]["owner"]["id"] == first_account.id - assert result1.data["TestPerson"]["edges"][0]["node"]["name"]["owner"][ - "display_label" - ] == await first_account.get_display_label(db=db) - assert result1.data["TestPerson"]["edges"][0]["node"]["name"]["is_from_profile"] is False - assert gql_params.context.related_node_ids == {p1.id, first_account.id} - - # test relationship-level query - query = """ - query { - TestCar { - edges { - node { - id - owner { - node { - id - name { - value - owner { - id - display_label - } - is_from_profile - } - } - } - } - } - } - - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result2 = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result2.errors is None - - assert result2.data - assert result2.data["TestCar"]["edges"][0]["node"]["owner"]["node"]["name"]["owner"] - assert result2.data["TestCar"]["edges"][0]["node"]["owner"]["node"]["name"]["owner"]["id"] == first_account.id - assert result2.data["TestCar"]["edges"][0]["node"]["owner"]["node"]["name"]["owner"][ - "display_label" - ] == await first_account.get_display_label(db=db) - assert result2.data["TestCar"]["edges"][0]["node"]["owner"]["node"]["name"]["is_from_profile"] is False - assert gql_params.context.related_node_ids == {c1.id, p1.id, first_account.id} - - -async def test_query_relationship_node_property( - db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch, first_account: Node -) -> None: - car = registry.schema.get(name="TestCar") - person = registry.schema.get(name="TestPerson") - - p1 = await Node.init(db=db, schema=person) - await p1.new(db=db, name="John", height=180) - await p1.save(db=db) - p2 = await Node.init(db=db, schema=person) - await p2.new(db=db, name="Jane", height=170) - await p2.save(db=db) - - c1 = await Node.init(db=db, schema=car) - await c1.new( - db=db, - name="volt", - nbr_seats=4, - is_electric=True, - owner={"id": p1, "_relation__owner": first_account.id}, - ) - await c1.save(db=db) - c2 = await Node.init(db=db, schema=car) - await c2.new( - db=db, - name="bolt", - nbr_seats=4, - is_electric=True, - owner={"id": p2, "_relation__source": first_account.id}, - ) - await c2.save(db=db) - - # test many relationship query - query = """ - query { - TestPerson { - edges { - node { - id - name { - value - } - cars { - edges { - node { - name { - value - } - } - properties { - owner { - id - } - source { - id - } - } - } - } - } - } - } - } - """ - - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - assert result.errors is None - assert result.data - results = {item["node"]["name"]["value"]: item["node"] for item in result.data["TestPerson"]["edges"]} - assert sorted(results.keys()) == ["Jane", "John"] - assert len(results["John"]["cars"]["edges"]) == 1 - assert len(results["Jane"]["cars"]["edges"]) == 1 - - assert results["John"]["cars"]["edges"][0]["node"]["name"]["value"] == "volt" - assert results["John"]["cars"]["edges"][0]["properties"]["owner"] - assert results["John"]["cars"]["edges"][0]["properties"]["owner"]["id"] == first_account.id - assert results["John"]["cars"]["edges"][0]["properties"]["source"] is None - - assert results["Jane"]["cars"]["edges"][0]["node"]["name"]["value"] == "bolt" - assert results["Jane"]["cars"]["edges"][0]["properties"]["owner"] is None - assert results["Jane"]["cars"]["edges"][0]["properties"]["source"] - assert results["Jane"]["cars"]["edges"][0]["properties"]["source"]["id"] == first_account.id - assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, first_account.id} - - # test single relationship query - query = """ - query { - TestCar { - edges { - node { - id - name { - value - } - owner { - node { - name { - value - } - } - properties { - owner { - id - } - source { - id - } - } - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - assert result.errors is None - - assert result.data - results = {item["node"]["name"]["value"]: item["node"] for item in result.data["TestCar"]["edges"]} - assert set(results.keys()) == {"volt", "bolt"} - - assert results["volt"]["owner"]["node"]["name"]["value"] == "John" - assert results["volt"]["owner"]["properties"]["owner"] - assert results["volt"]["owner"]["properties"]["owner"]["id"] == first_account.id - assert results["volt"]["owner"]["properties"]["source"] is None - - assert results["bolt"]["owner"]["node"]["name"]["value"] == "Jane" - assert results["bolt"]["owner"]["properties"]["owner"] is None - assert results["bolt"]["owner"]["properties"]["source"] - assert results["bolt"]["owner"]["properties"]["source"]["id"] == first_account.id - assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, first_account.id} - - # test many relationship query with mixed properties on peer - query = """ - query { - people_with_cars_and_owners: TestPerson { - edges { - node { - id - name { - value - } - cars { - edges { - node { - name { - value - } - } - properties { - owner { - id - } - } - } - } - } - } - } - people_with_cars_and_sources: TestPerson { - edges { - node { - id - name { - value - } - cars { - edges { - node { - name { - value - } - } - properties { - source { - id - } - } + + # test node-level query + query = """ + query { + TestPerson { + edges { + node { + id + name { + value + owner { + id + display_label } + is_from_profile } } } } } """ - default_branch.update_schema_hash() gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( + result1 = await graphql( schema=gql_params.schema, source=query, context_value=gql_params.context, root_value=None, variable_values={}, ) - assert result.errors is None - assert result.data - - owner_results = { - item["node"]["name"]["value"]: item["node"] for item in result.data["people_with_cars_and_owners"]["edges"] - } - assert sorted(owner_results.keys()) == ["Jane", "John"] - assert len(owner_results["John"]["cars"]["edges"]) == 1 - assert len(owner_results["Jane"]["cars"]["edges"]) == 1 - - assert owner_results["John"]["cars"]["edges"][0]["node"]["name"]["value"] == "volt" - assert owner_results["John"]["cars"]["edges"][0]["properties"]["owner"] - assert owner_results["John"]["cars"]["edges"][0]["properties"]["owner"]["id"] == first_account.id - assert "source" not in owner_results["John"]["cars"]["edges"][0]["properties"] - - assert owner_results["Jane"]["cars"]["edges"][0]["node"]["name"]["value"] == "bolt" - assert owner_results["Jane"]["cars"]["edges"][0]["properties"]["owner"] is None - assert "source" not in owner_results["Jane"]["cars"]["edges"][0]["properties"] - - source_results = { - item["node"]["name"]["value"]: item["node"] for item in result.data["people_with_cars_and_sources"]["edges"] - } - assert sorted(source_results.keys()) == ["Jane", "John"] - assert len(source_results["John"]["cars"]["edges"]) == 1 - assert len(source_results["Jane"]["cars"]["edges"]) == 1 - - assert source_results["John"]["cars"]["edges"][0]["node"]["name"]["value"] == "volt" - assert "owner" not in source_results["John"]["cars"]["edges"][0]["properties"] - assert source_results["John"]["cars"]["edges"][0]["properties"]["source"] is None - - assert source_results["Jane"]["cars"]["edges"][0]["node"]["name"]["value"] == "bolt" - assert "owner" not in source_results["Jane"]["cars"]["edges"][0]["properties"] - assert source_results["Jane"]["cars"]["edges"][0]["properties"]["source"] - assert source_results["Jane"]["cars"]["edges"][0]["properties"]["source"]["id"] == first_account.id - - assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, first_account.id} + assert result1.errors is None + assert result1.data + assert result1.data["TestPerson"]["edges"][0]["node"]["name"]["owner"] + assert result1.data["TestPerson"]["edges"][0]["node"]["name"]["owner"]["id"] == first_account.id + assert result1.data["TestPerson"]["edges"][0]["node"]["name"]["owner"][ + "display_label" + ] == await first_account.get_display_label(db=db) + assert result1.data["TestPerson"]["edges"][0]["node"]["name"]["is_from_profile"] is False + assert gql_params.context.related_node_ids == {p1.id, first_account.id} -async def test_same_many_relationship_with_different_limits_offsets( - db: InfrahubDatabase, - default_branch: Branch, - person_john_main: Node, - person_jane_main: Node, - car_accord_main: Node, - car_prius_main: Node, - car_camry_main: Node, - car_yaris_main: Node, -) -> None: + # test relationship-level query query = """ query { - people_with_cars_1: TestPerson { - edges { - node { - id - name { - value - } - cars(limit: 1, offset: 0) { - edges { - node { - id - } - } - } - } - } - } - people_with_cars_2: TestPerson { + TestCar { edges { node { id - name { - value - } - cars(limit: 1, offset: 1) { - edges { - node { - id + owner { + node { + id + name { + value + owner { + id + display_label + } + is_from_profile } } } } } } + } """ - john_cars_by_uuid = sorted([car_accord_main, car_prius_main], key=lambda c: c.id) - jane_cars_by_uuid = sorted([car_camry_main, car_yaris_main], key=lambda c: c.id) - default_branch.update_schema_hash() gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( + result2 = await graphql( schema=gql_params.schema, source=query, context_value=gql_params.context, root_value=None, variable_values={}, ) - assert result.errors is None - assert result.data - for person_node in result.data["people_with_cars_1"]["edges"]: - person_name = person_node["node"]["name"]["value"] - assert len(person_node["node"]["cars"]["edges"]) == 1 - if person_name == "John": - assert person_node["node"]["cars"]["edges"][0]["node"]["id"] == john_cars_by_uuid[0].id - elif person_name == "Jane": - assert person_node["node"]["cars"]["edges"][0]["node"]["id"] == jane_cars_by_uuid[0].id - for person_node in result.data["people_with_cars_2"]["edges"]: - person_name = person_node["node"]["name"]["value"] - assert len(person_node["node"]["cars"]["edges"]) == 1 - if person_name == "John": - assert person_node["node"]["cars"]["edges"][0]["node"]["id"] == john_cars_by_uuid[1].id - elif person_name == "Jane": - assert person_node["node"]["cars"]["edges"][0]["node"]["id"] == jane_cars_by_uuid[1].id + assert result2.errors is None + + assert result2.data + assert result2.data["TestCar"]["edges"][0]["node"]["owner"]["node"]["name"]["owner"] + assert result2.data["TestCar"]["edges"][0]["node"]["owner"]["node"]["name"]["owner"]["id"] == first_account.id + assert result2.data["TestCar"]["edges"][0]["node"]["owner"]["node"]["name"]["owner"][ + "display_label" + ] == await first_account.get_display_label(db=db) + assert result2.data["TestCar"]["edges"][0]["node"]["owner"]["node"]["name"]["is_from_profile"] is False + assert gql_params.context.related_node_ids == {c1.id, p1.id, first_account.id} async def test_query_attribute_flag_property( @@ -2707,129 +1775,6 @@ async def test_model_node_interface(db: InfrahubDatabase, default_branch: Branch assert gql_params.context.related_node_ids == {d1.id, d2.id} -async def test_model_rel_interface(db: InfrahubDatabase, default_branch: Branch, vehicule_person_schema: None) -> None: - d1 = await Node.init(db=db, schema="TestCar") - await d1.new(db=db, name="Porsche 911", nbr_doors=2) - await d1.save(db=db) - - b1 = await Node.init(db=db, schema="TestBoat") - await b1.new(db=db, name="Laser", has_sails=True) - await b1.save(db=db) - - p1 = await Node.init(db=db, schema="TestPerson") - await p1.new(db=db, name="John Doe", vehicules=[d1, b1]) - await p1.save(db=db) - - query = """ - query { - TestPerson { - edges { - node { - name { - value - } - vehicules { - edges { - node { - name { - value - } - ... on TestCar { - nbr_doors { - value - } - } - ... on TestBoat { - has_sails { - value - } - } - } - } - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert len(result.data["TestPerson"]["edges"][0]["node"]["vehicules"]["edges"]) == 2 - expected_results = { - "name": {"value": "John Doe"}, - "vehicules": { - "edges": [ - {"node": {"name": {"value": "Porsche 911"}, "nbr_doors": {"value": 2}}}, - {"node": {"has_sails": {"value": True}, "name": {"value": "Laser"}}}, - ] - }, - } - assert DeepDiff(result.data["TestPerson"]["edges"][0]["node"], expected_results, ignore_order=True).to_dict() == {} - - -async def test_model_rel_interface_reverse( - db: InfrahubDatabase, default_branch: Branch, vehicule_person_schema: None -) -> None: - d1 = await Node.init(db=db, schema="TestCar") - await d1.new(db=db, name="Porsche 911", nbr_doors=2) - await d1.save(db=db) - - b1 = await Node.init(db=db, schema="TestBoat") - await b1.new(db=db, name="Laser", has_sails=True) - await b1.save(db=db) - - p1 = await Node.init(db=db, schema="TestPerson") - await p1.new(db=db, name="John Doe", vehicules=[d1, b1]) - await p1.save(db=db) - - query = """ - query { - TestBoat { - edges { - node { - name { - value - } - owners { - edges { - node { - name { - value - } - } - } - - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - assert len(result.data["TestBoat"]["edges"][0]["node"]["owners"]["edges"]) == 1 - - async def test_generic_root_with_pagination( db: InfrahubDatabase, default_branch: Branch, car_person_generics_data: dict[str, Node] ) -> None: @@ -3310,133 +2255,6 @@ async def test_hierarchical_location_include_descendants( assert asia["things"]["count"] == 7 -async def test_properties_on_different_query_paths( - db: InfrahubDatabase, - default_branch: Branch, - hierarchical_location_data_thing: dict[str, Node], - account_bob: Node, - account_bill: Node, -) -> None: - paris_owner = account_bob - paris_rack_ids = [node.id for name, node in hierarchical_location_data_thing.items() if name.startswith("paris-r")] - paris_racks = await NodeManager.get_many(db=db, ids=paris_rack_ids) - for rack in paris_racks.values(): - thing_rels = await rack.things.get_relationships(db=db) - await rack.things.update( - db=db, data=[{"id": rel.peer_id, "_relation__owner": paris_owner.id} for rel in thing_rels] - ) - await rack.save(db=db) - - london_source = account_bill - london_rack_ids = [ - node.id for name, node in hierarchical_location_data_thing.items() if name.startswith("london-r") - ] - london_racks = await NodeManager.get_many(db=db, ids=london_rack_ids) - for rack in london_racks.values(): - thing_rels = await rack.things.get_relationships(db=db) - await rack.things.update( - db=db, data=[{"id": rel.peer_id, "_relation__source": london_source.id} for rel in thing_rels] - ) - await rack.save(db=db) - - query = """ - query GetRack { - LocationRack(parent__name__values: "europe") { - edges { - node { - id - name { - value - } - things { - edges { - properties { - owner { - id - } - } - node { - id - name { - value - } - } - } - } - } - } - } - LocationSite(parent__name__values: "europe") { - edges { - node { - id - name { - value - } - children { - edges { - node { - name { - value - } - things { - edges { - properties { - source { - id - } - } - node { - id - name { - value - } - } - } - } - } - } - } - } - } - } - } - """ - default_branch.update_schema_hash() - gql_params = await prepare_graphql_params(db=db, branch=default_branch) - result = await graphql( - schema=gql_params.schema, - source=query, - context_value=gql_params.context, - root_value=None, - variable_values={}, - ) - - assert result.errors is None - assert result.data - - # check owners are correct - for rack in result.data["LocationRack"]["edges"]: - rack_name = rack["node"]["name"]["value"] - for thing_rel in rack["node"]["things"]["edges"]: - assert "source" not in thing_rel["properties"] - if rack_name.startswith("paris"): - assert thing_rel["properties"]["owner"]["id"] == paris_owner.id - else: - assert thing_rel["properties"]["owner"] is None - - # check sources are correct - for site in result.data["LocationSite"]["edges"]: - for rack in site["node"]["children"]["edges"]: - rack_name = rack["node"]["name"]["value"] - for thing_rel in rack["node"]["things"]["edges"]: - assert "owner" not in thing_rel["properties"] - if rack_name.startswith("london"): - assert thing_rel["properties"]["source"]["id"] == london_source.id - else: - assert thing_rel["properties"]["source"] is None - - async def test_hierarchical_groups_descendants( db: InfrahubDatabase, default_branch: Branch, hierarchical_groups_data: dict[str, Node] ) -> None: diff --git a/backend/tests/component/graphql/test_graphql_read_rel_query.py b/backend/tests/component/graphql/test_graphql_read_rel_query.py new file mode 100644 index 00000000000..221aac70e8d --- /dev/null +++ b/backend/tests/component/graphql/test_graphql_read_rel_query.py @@ -0,0 +1,1303 @@ +from copy import deepcopy +from typing import Any + +import pytest +from deepdiff import DeepDiff + +from infrahub.core import registry +from infrahub.core.branch import Branch +from infrahub.core.constants import InfrahubKind +from infrahub.core.manager import NodeManager +from infrahub.core.node import Node +from infrahub.core.schema import SchemaRoot +from infrahub.core.schema.schema_branch import SchemaBranch +from infrahub.core.timestamp import Timestamp +from infrahub.database import InfrahubDatabase +from infrahub.graphql.initialization import prepare_graphql_params +from tests.helpers.graphql import graphql + + +async def test_display_hfid_related_node( + db: InfrahubDatabase, default_branch: Branch, animal_person_schema: SchemaBranch +) -> None: + person_schema = animal_person_schema.get_node(name="TestPerson") + dog_schema = animal_person_schema.get_node(name="TestDog") + + person1 = await Node.init(db=db, schema=person_schema, branch=default_branch) + await person1.new(db=db, name="Jack") + await person1.save(db=db) + + dog1 = await Node.init(db=db, schema=dog_schema, branch=default_branch) + await dog1.new(db=db, name="Rocky", breed="Labrador", owner=person1) + await dog1.save(db=db) + + query = """ + query { + TestPerson { + edges { + node { + hfid + animals { + edges { + node { + hfid + } + } + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data + assert len(result.data["TestPerson"]["edges"]) == 1 + assert result.data["TestPerson"]["edges"][0] == { + "node": { + "animals": {"edges": [{"node": {"hfid": ["Jack", "Rocky"]}}]}, + "hfid": ["Jack"], + }, + } + + +async def test_nested_query(db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch) -> None: + car = registry.schema.get_node_schema(name="TestCar") + person = registry.schema.get_node_schema(name="TestPerson") + + p1 = await Node.init(db=db, schema=person) + await p1.new(db=db, name="John", height=180) + await p1.save(db=db) + p2 = await Node.init(db=db, schema=person) + await p2.new(db=db, name="Jane", height=170) + await p2.save(db=db) + + c1 = await Node.init(db=db, schema=car) + await c1.new(db=db, name="volt", nbr_seats=4, is_electric=True, owner=p1) + await c1.save(db=db) + c2 = await Node.init(db=db, schema=car) + await c2.new(db=db, name="bolt", nbr_seats=4, is_electric=True, owner=p1) + await c2.save(db=db) + c3 = await Node.init(db=db, schema=car) + await c3.new(db=db, name="nolt", nbr_seats=4, is_electric=True, owner=p2) + await c3.save(db=db) + + query = """ + query { + TestPerson { + edges { + node { + name { + value + } + cars { + edges { + node { + name { + value + } + } + } + } + } + } + } + } + """ + + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + + assert result.data + result_per_name = {result["node"]["name"]["value"]: result["node"] for result in result.data["TestPerson"]["edges"]} + assert sorted(result_per_name.keys()) == ["Jane", "John"] + assert len(result_per_name["John"]["cars"]["edges"]) == 2 + assert len(result_per_name["Jane"]["cars"]["edges"]) == 1 + assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, c3.id} + + +async def test_double_nested_query( + db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch +) -> None: + car = registry.schema.get_node_schema(name="TestCar") + person = registry.schema.get_node_schema(name="TestPerson") + + p1 = await Node.init(db=db, schema=person) + await p1.new(db=db, name="John", height=180) + await p1.save(db=db) + p2 = await Node.init(db=db, schema=person) + await p2.new(db=db, name="Jane", height=170) + await p2.save(db=db) + + c1 = await Node.init(db=db, schema=car) + await c1.new(db=db, name="volt", nbr_seats=4, is_electric=True, owner=p1) + await c1.save(db=db) + c2 = await Node.init(db=db, schema=car) + await c2.new(db=db, name="bolt", nbr_seats=4, is_electric=True, owner=p1) + await c2.save(db=db) + c3 = await Node.init(db=db, schema=car) + await c3.new(db=db, name="nolt", nbr_seats=4, is_electric=True, owner=p2) + await c3.save(db=db) + + query = """ + query { + TestPerson { + edges { + node { + name { + value + } + cars { + count + edges { + node { + name { + value + } + owner { + node { + name { + value + } + } + } + } + } + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + + assert result.data + result_per_name = {result["node"]["name"]["value"]: result["node"] for result in result.data["TestPerson"]["edges"]} + assert sorted(result_per_name.keys()) == ["Jane", "John"] + assert len(result_per_name["John"]["cars"]["edges"]) == 2 + assert len(result_per_name["Jane"]["cars"]["edges"]) == 1 + assert result_per_name["John"]["cars"]["count"] == 2 + assert result_per_name["Jane"]["cars"]["count"] == 1 + assert result_per_name["John"]["cars"]["edges"][0]["node"]["owner"]["node"]["name"]["value"] == "John" + + assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, c3.id} + + +async def test_nested_query_single_relationship( + db: InfrahubDatabase, default_branch: Branch, node_group_schema: None, data_schema: None +) -> None: + raw_schema = { + "version": "1.0", + "generics": [ + { + "name": "Generic", + "namespace": "Location", + "hierarchical": True, + "attributes": [{"name": "name", "optional": False, "kind": "Text"}], + "relationships": [{"name": "devices", "peer": "InfraDevice", "cardinality": "many", "optional": True}], + } + ], + "nodes": [ + { + "name": "Device", + "namespace": "Infra", + "attributes": [{"name": "name", "kind": "Text", "optional": False}], + "relationships": [ + {"name": "location", "peer": "LocationGeneric", "optional": False, "cardinality": "one"} + ], + }, + { + "name": "Site", + "namespace": "Location", + "inherit_from": ["LocationGeneric"], + "attributes": [{"name": "description", "optional": False, "kind": "Text"}], + }, + ], + } + schema = SchemaRoot(**raw_schema) + schema_branch = registry.schema.register_schema(schema=schema, branch=default_branch.name) + + site_schema = schema_branch.get_node(name="LocationSite") + device_schema = schema_branch.get_node(name="InfraDevice") + + site1 = await Node.init(db=db, schema=site_schema, branch=default_branch) + await site1.new(db=db, name="site1", description="test") + await site1.save(db=db) + + device1 = await Node.init(db=db, schema=device_schema, branch=default_branch) + await device1.new(db=db, name="device1", location=site1) + await device1.save(db=db) + + device2 = await Node.init(db=db, schema=device_schema, branch=default_branch) + await device2.new(db=db, name="device2", location=site1) + await device2.save(db=db) + + query = """ + fragment LocationData on LocationSite { + name { + value + } + devices { + edges { + node { + name { + value + } + } + } + } + } + + query { + InfraDevice { + edges { + node { + name { + value + } + location { + node { + ... LocationData + } + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data + result_per_name = { + result["node"]["name"]["value"]: result["node"] for result in result.data["InfraDevice"]["edges"] + } + assert sorted(result_per_name.keys()) == ["device1", "device2"] + expected_location_data = { + "node": { + "name": {"value": "site1"}, + "devices": {"edges": [{"node": {"name": {"value": "device1"}}}, {"node": {"name": {"value": "device2"}}}]}, + } + } + assert result.data["InfraDevice"]["edges"][0]["node"]["location"] == expected_location_data + assert result.data["InfraDevice"]["edges"][1]["node"]["location"] == expected_location_data + + +async def test_nested_generic_query_many_relationship( + db: InfrahubDatabase, default_branch: Branch, node_group_schema: None, data_schema: None +) -> None: + """Validates that nested GraphQL fragments work for cardinality=many relationships.""" + raw_schema = { + "version": "1.0", + "generics": [ + { + "name": "Generic", + "namespace": "Location", + "hierarchical": True, + "attributes": [{"name": "name", "optional": False, "kind": "Text"}], + "relationships": [{"name": "devices", "peer": "InfraDevice", "cardinality": "many", "optional": True}], + } + ], + "nodes": [ + { + "name": "Device", + "namespace": "Infra", + "attributes": [{"name": "name", "kind": "Text", "optional": False}], + "relationships": [ + {"name": "location", "peer": "LocationGeneric", "optional": False, "cardinality": "one"} + ], + }, + { + "name": "Site", + "namespace": "Location", + "inherit_from": ["LocationGeneric"], + "attributes": [{"name": "description", "optional": False, "kind": "Text"}], + }, + ], + } + schema = SchemaRoot(**raw_schema) + schema_branch = registry.schema.register_schema(schema=schema, branch=default_branch.name) + + site_schema = schema_branch.get_node(name="LocationSite") + device_schema = schema_branch.get_node(name="InfraDevice") + + site1 = await Node.init(db=db, schema=site_schema, branch=default_branch) + await site1.new(db=db, name="site1", description="test") + await site1.save(db=db) + + device1 = await Node.init(db=db, schema=device_schema, branch=default_branch) + await device1.new(db=db, name="device1", location=site1) + await device1.save(db=db) + + device2 = await Node.init(db=db, schema=device_schema, branch=default_branch) + await device2.new(db=db, name="device2", location=site1) + await device2.save(db=db) + + query = """ + fragment DeviceData on InfraDevice { + name { + value + } + } + + fragment LocationData on LocationSite { + name { + value + } + devices { + edges { + node { + ...DeviceData + } + } + } + } + + query { + LocationSite { + edges { + node { + ...LocationData + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + + assert result.data == { + "LocationSite": { + "edges": [ + { + "node": { + "name": {"value": "site1"}, + "devices": { + "edges": [ + {"node": {"name": {"value": "device1"}}}, + {"node": {"name": {"value": "device2"}}}, + ] + }, + } + } + ] + } + } + + +async def test_query_relationship_multiple_values( + db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch +) -> None: + car = registry.schema.get(name="TestCar") + person = registry.schema.get(name="TestPerson") + + p1 = await Node.init(db=db, schema=person) + await p1.new(db=db, name="John", height=180) + await p1.save(db=db) + p2 = await Node.init(db=db, schema=person) + + await p2.new(db=db, name="Jane", height=170) + await p2.save(db=db) + + c1 = await Node.init(db=db, schema=car) + await c1.new(db=db, name="volt", nbr_seats=4, is_electric=True, owner=p1) + await c1.save(db=db) + c2 = await Node.init(db=db, schema=car) + await c2.new(db=db, name="bolt", nbr_seats=4, is_electric=True, owner=p1) + await c2.save(db=db) + c3 = await Node.init(db=db, schema=car) + await c3.new(db=db, name="nolt", nbr_seats=4, is_electric=True, owner=p2) + await c3.save(db=db) + c4 = await Node.init(db=db, schema=car) + await c4.new(db=db, name="yaris", nbr_seats=5, is_electric=False, owner=p1) + await c4.save(db=db) + + query = """ + query { + TestPerson { + edges { + node { + name { + value + } + cars (name__values: ["volt", "nolt"]) { + edges { + node { + name { + value + } + } + } + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data + assert len(result.data["TestPerson"]["edges"]) == 2 + assert result.data["TestPerson"]["edges"][0]["node"]["cars"]["edges"][0]["node"]["name"]["value"] == "volt" + assert result.data["TestPerson"]["edges"][1]["node"]["cars"]["edges"][0]["node"]["name"]["value"] == "nolt" + + +async def test_query_oneway_relationship(db: InfrahubDatabase, default_branch: Branch, person_tag_schema: None) -> None: + t1 = await Node.init(db=db, schema=InfrahubKind.TAG) + await t1.new(db=db, name="Blue", description="The Blue tag") + await t1.save(db=db) + t2 = await Node.init(db=db, schema=InfrahubKind.TAG) + await t2.new(db=db, name="Red") + await t2.save(db=db) + p1 = await Node.init(db=db, schema="TestPerson") + await p1.new(db=db, firstname="John", lastname="Doe", tags=[t1, t2]) + await p1.save(db=db) + + query = """ + query { + TestPerson { + edges { + node { + id + tags { + edges { + node { + name { + value + } + } + } + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data + assert len(result.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"]) == 2 + + +async def test_query_relationship_updated_at( + db: InfrahubDatabase, default_branch: Branch, person_tag_schema: None +) -> None: + t1 = await Node.init(db=db, schema=InfrahubKind.TAG) + await t1.new(db=db, name="Blue", description="The Blue tag") + await t1.save(db=db) + t2 = await Node.init(db=db, schema=InfrahubKind.TAG) + await t2.new(db=db, name="Red") + await t2.save(db=db) + + query = """ + query { + TestPerson { + edges { + node { + id + tags { + edges { + node_metadata { + updated_at + } + node { + name { + value + } + } + properties { + updated_at + } + } + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result1 = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result1.errors is None + assert result1.data + assert result1.data["TestPerson"]["edges"] == [] + + p1 = await Node.init(db=db, schema="TestPerson") + await p1.new(db=db, firstname="John", lastname="Doe", tags=[t1, t2]) + await p1.save(db=db) + + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result2 = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result2.errors is None + assert result2.data + assert len(result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"]) == 2 + assert result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["node_metadata"]["updated_at"] is not None + assert ( + result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["node_metadata"]["updated_at"] + != result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["properties"]["updated_at"] + ) + assert result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["node_metadata"][ + "updated_at" + ] == Timestamp( + result2.data["TestPerson"]["edges"][0]["node"]["tags"]["edges"][0]["node_metadata"]["updated_at"] + ).to_string(with_z=False) + + +async def test_query_relationship_node_property( + db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch, first_account: Node +) -> None: + car = registry.schema.get(name="TestCar") + person = registry.schema.get(name="TestPerson") + + p1 = await Node.init(db=db, schema=person) + await p1.new(db=db, name="John", height=180) + await p1.save(db=db) + p2 = await Node.init(db=db, schema=person) + await p2.new(db=db, name="Jane", height=170) + await p2.save(db=db) + + c1 = await Node.init(db=db, schema=car) + await c1.new( + db=db, + name="volt", + nbr_seats=4, + is_electric=True, + owner={"id": p1, "_relation__owner": first_account.id}, + ) + await c1.save(db=db) + c2 = await Node.init(db=db, schema=car) + await c2.new( + db=db, + name="bolt", + nbr_seats=4, + is_electric=True, + owner={"id": p2, "_relation__source": first_account.id}, + ) + await c2.save(db=db) + + # test many relationship query + query = """ + query { + TestPerson { + edges { + node { + id + name { + value + } + cars { + edges { + node { + name { + value + } + } + properties { + owner { + id + } + source { + id + } + } + } + } + } + } + } + } + """ + + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + assert result.errors is None + assert result.data + results = {item["node"]["name"]["value"]: item["node"] for item in result.data["TestPerson"]["edges"]} + assert sorted(results.keys()) == ["Jane", "John"] + assert len(results["John"]["cars"]["edges"]) == 1 + assert len(results["Jane"]["cars"]["edges"]) == 1 + + assert results["John"]["cars"]["edges"][0]["node"]["name"]["value"] == "volt" + assert results["John"]["cars"]["edges"][0]["properties"]["owner"] + assert results["John"]["cars"]["edges"][0]["properties"]["owner"]["id"] == first_account.id + assert results["John"]["cars"]["edges"][0]["properties"]["source"] is None + + assert results["Jane"]["cars"]["edges"][0]["node"]["name"]["value"] == "bolt" + assert results["Jane"]["cars"]["edges"][0]["properties"]["owner"] is None + assert results["Jane"]["cars"]["edges"][0]["properties"]["source"] + assert results["Jane"]["cars"]["edges"][0]["properties"]["source"]["id"] == first_account.id + assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, first_account.id} + + # test single relationship query + query = """ + query { + TestCar { + edges { + node { + id + name { + value + } + owner { + node { + name { + value + } + } + properties { + owner { + id + } + source { + id + } + } + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + assert result.errors is None + + assert result.data + results = {item["node"]["name"]["value"]: item["node"] for item in result.data["TestCar"]["edges"]} + assert set(results.keys()) == {"volt", "bolt"} + + assert results["volt"]["owner"]["node"]["name"]["value"] == "John" + assert results["volt"]["owner"]["properties"]["owner"] + assert results["volt"]["owner"]["properties"]["owner"]["id"] == first_account.id + assert results["volt"]["owner"]["properties"]["source"] is None + + assert results["bolt"]["owner"]["node"]["name"]["value"] == "Jane" + assert results["bolt"]["owner"]["properties"]["owner"] is None + assert results["bolt"]["owner"]["properties"]["source"] + assert results["bolt"]["owner"]["properties"]["source"]["id"] == first_account.id + assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, first_account.id} + + # test many relationship query with mixed properties on peer + query = """ + query { + people_with_cars_and_owners: TestPerson { + edges { + node { + id + name { + value + } + cars { + edges { + node { + name { + value + } + } + properties { + owner { + id + } + } + } + } + } + } + } + people_with_cars_and_sources: TestPerson { + edges { + node { + id + name { + value + } + cars { + edges { + node { + name { + value + } + } + properties { + source { + id + } + } + } + } + } + } + } + } + """ + + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + assert result.errors is None + assert result.data + + owner_results = { + item["node"]["name"]["value"]: item["node"] for item in result.data["people_with_cars_and_owners"]["edges"] + } + assert sorted(owner_results.keys()) == ["Jane", "John"] + assert len(owner_results["John"]["cars"]["edges"]) == 1 + assert len(owner_results["Jane"]["cars"]["edges"]) == 1 + + assert owner_results["John"]["cars"]["edges"][0]["node"]["name"]["value"] == "volt" + assert owner_results["John"]["cars"]["edges"][0]["properties"]["owner"] + assert owner_results["John"]["cars"]["edges"][0]["properties"]["owner"]["id"] == first_account.id + assert "source" not in owner_results["John"]["cars"]["edges"][0]["properties"] + + assert owner_results["Jane"]["cars"]["edges"][0]["node"]["name"]["value"] == "bolt" + assert owner_results["Jane"]["cars"]["edges"][0]["properties"]["owner"] is None + assert "source" not in owner_results["Jane"]["cars"]["edges"][0]["properties"] + + source_results = { + item["node"]["name"]["value"]: item["node"] for item in result.data["people_with_cars_and_sources"]["edges"] + } + assert sorted(source_results.keys()) == ["Jane", "John"] + assert len(source_results["John"]["cars"]["edges"]) == 1 + assert len(source_results["Jane"]["cars"]["edges"]) == 1 + + assert source_results["John"]["cars"]["edges"][0]["node"]["name"]["value"] == "volt" + assert "owner" not in source_results["John"]["cars"]["edges"][0]["properties"] + assert source_results["John"]["cars"]["edges"][0]["properties"]["source"] is None + + assert source_results["Jane"]["cars"]["edges"][0]["node"]["name"]["value"] == "bolt" + assert "owner" not in source_results["Jane"]["cars"]["edges"][0]["properties"] + assert source_results["Jane"]["cars"]["edges"][0]["properties"]["source"] + assert source_results["Jane"]["cars"]["edges"][0]["properties"]["source"]["id"] == first_account.id + + assert gql_params.context.related_node_ids == {p1.id, p2.id, c1.id, c2.id, first_account.id} + + +async def test_same_many_relationship_with_different_limits_offsets( + db: InfrahubDatabase, + default_branch: Branch, + person_john_main: Node, + person_jane_main: Node, + car_accord_main: Node, + car_prius_main: Node, + car_camry_main: Node, + car_yaris_main: Node, +) -> None: + query = """ + query { + people_with_cars_1: TestPerson { + edges { + node { + id + name { + value + } + cars(limit: 1, offset: 0) { + edges { + node { + id + } + } + } + } + } + } + people_with_cars_2: TestPerson { + edges { + node { + id + name { + value + } + cars(limit: 1, offset: 1) { + edges { + node { + id + } + } + } + } + } + } + } + """ + john_cars_by_uuid = sorted([car_accord_main, car_prius_main], key=lambda c: c.id) + jane_cars_by_uuid = sorted([car_camry_main, car_yaris_main], key=lambda c: c.id) + + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + assert result.errors is None + assert result.data + + for person_node in result.data["people_with_cars_1"]["edges"]: + person_name = person_node["node"]["name"]["value"] + assert len(person_node["node"]["cars"]["edges"]) == 1 + if person_name == "John": + assert person_node["node"]["cars"]["edges"][0]["node"]["id"] == john_cars_by_uuid[0].id + elif person_name == "Jane": + assert person_node["node"]["cars"]["edges"][0]["node"]["id"] == jane_cars_by_uuid[0].id + for person_node in result.data["people_with_cars_2"]["edges"]: + person_name = person_node["node"]["name"]["value"] + assert len(person_node["node"]["cars"]["edges"]) == 1 + if person_name == "John": + assert person_node["node"]["cars"]["edges"][0]["node"]["id"] == john_cars_by_uuid[1].id + elif person_name == "Jane": + assert person_node["node"]["cars"]["edges"][0]["node"]["id"] == jane_cars_by_uuid[1].id + + +async def test_model_rel_interface(db: InfrahubDatabase, default_branch: Branch, vehicule_person_schema: None) -> None: + d1 = await Node.init(db=db, schema="TestCar") + await d1.new(db=db, name="Porsche 911", nbr_doors=2) + await d1.save(db=db) + + b1 = await Node.init(db=db, schema="TestBoat") + await b1.new(db=db, name="Laser", has_sails=True) + await b1.save(db=db) + + p1 = await Node.init(db=db, schema="TestPerson") + await p1.new(db=db, name="John Doe", vehicules=[d1, b1]) + await p1.save(db=db) + + query = """ + query { + TestPerson { + edges { + node { + name { + value + } + vehicules { + edges { + node { + name { + value + } + ... on TestCar { + nbr_doors { + value + } + } + ... on TestBoat { + has_sails { + value + } + } + } + } + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data + assert len(result.data["TestPerson"]["edges"][0]["node"]["vehicules"]["edges"]) == 2 + expected_results = { + "name": {"value": "John Doe"}, + "vehicules": { + "edges": [ + {"node": {"name": {"value": "Porsche 911"}, "nbr_doors": {"value": 2}}}, + {"node": {"has_sails": {"value": True}, "name": {"value": "Laser"}}}, + ] + }, + } + assert DeepDiff(result.data["TestPerson"]["edges"][0]["node"], expected_results, ignore_order=True).to_dict() == {} + + +async def test_model_rel_interface_reverse( + db: InfrahubDatabase, default_branch: Branch, vehicule_person_schema: None +) -> None: + d1 = await Node.init(db=db, schema="TestCar") + await d1.new(db=db, name="Porsche 911", nbr_doors=2) + await d1.save(db=db) + + b1 = await Node.init(db=db, schema="TestBoat") + await b1.new(db=db, name="Laser", has_sails=True) + await b1.save(db=db) + + p1 = await Node.init(db=db, schema="TestPerson") + await p1.new(db=db, name="John Doe", vehicules=[d1, b1]) + await p1.save(db=db) + + query = """ + query { + TestBoat { + edges { + node { + name { + value + } + owners { + edges { + node { + name { + value + } + } + } + + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data + assert len(result.data["TestBoat"]["edges"][0]["node"]["owners"]["edges"]) == 1 + + +async def test_properties_on_different_query_paths( + db: InfrahubDatabase, + default_branch: Branch, + hierarchical_location_data_thing: dict[str, Node], + account_bob: Node, + account_bill: Node, +) -> None: + paris_owner = account_bob + paris_rack_ids = [node.id for name, node in hierarchical_location_data_thing.items() if name.startswith("paris-r")] + paris_racks = await NodeManager.get_many(db=db, ids=paris_rack_ids) + for rack in paris_racks.values(): + thing_rels = await rack.things.get_relationships(db=db) + await rack.things.update( + db=db, data=[{"id": rel.peer_id, "_relation__owner": paris_owner.id} for rel in thing_rels] + ) + await rack.save(db=db) + + london_source = account_bill + london_rack_ids = [ + node.id for name, node in hierarchical_location_data_thing.items() if name.startswith("london-r") + ] + london_racks = await NodeManager.get_many(db=db, ids=london_rack_ids) + for rack in london_racks.values(): + thing_rels = await rack.things.get_relationships(db=db) + await rack.things.update( + db=db, data=[{"id": rel.peer_id, "_relation__source": london_source.id} for rel in thing_rels] + ) + await rack.save(db=db) + + query = """ + query GetRack { + LocationRack(parent__name__values: "europe") { + edges { + node { + id + name { + value + } + things { + edges { + properties { + owner { + id + } + } + node { + id + name { + value + } + } + } + } + } + } + } + LocationSite(parent__name__values: "europe") { + edges { + node { + id + name { + value + } + children { + edges { + node { + name { + value + } + things { + edges { + properties { + source { + id + } + } + node { + id + name { + value + } + } + } + } + } + } + } + } + } + } + } + """ + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data + + # check owners are correct + for rack in result.data["LocationRack"]["edges"]: + rack_name = rack["node"]["name"]["value"] + for thing_rel in rack["node"]["things"]["edges"]: + assert "source" not in thing_rel["properties"] + if rack_name.startswith("paris"): + assert thing_rel["properties"]["owner"]["id"] == paris_owner.id + else: + assert thing_rel["properties"]["owner"] is None + + # check sources are correct + for site in result.data["LocationSite"]["edges"]: + for rack in site["node"]["children"]["edges"]: + rack_name = rack["node"]["name"]["value"] + for thing_rel in rack["node"]["things"]["edges"]: + assert "owner" not in thing_rel["properties"] + if rack_name.startswith("london"): + assert thing_rel["properties"]["source"]["id"] == london_source.id + else: + assert thing_rel["properties"]["source"] is None + + +async def test_single_relationship_id_only_uses_preloaded_peer_id( + db: InfrahubDatabase, + default_branch: Branch, + animal_person_schema_unregistered: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify cardinality-one peer ID relationship shortcut + + TestAnimal is a generic, so favorite_animal is a cardinality-one relationship whose + GraphQL node field is an interface: the id-only shortcut can only answer it without + hydrating the peer when the preloaded stub carries the peer's concrete kind. owner + covers the same shortcut when the peer field is a concrete node type. + """ + schema_dict = deepcopy(animal_person_schema_unregistered) + person_node = next(node for node in schema_dict["nodes"] if node["name"] == "Person") + person_node["relationships"].append( + { + "name": "favorite_animal", + "peer": "TestAnimal", + "optional": True, + "identifier": "person__favorite_animal", + "cardinality": "one", + "direction": "outbound", + } + ) + schema_branch = registry.schema.register_schema(schema=SchemaRoot(**schema_dict), branch=default_branch.name) + + person_schema = schema_branch.get_node(name="TestPerson", duplicate=False) + dog_schema = schema_branch.get_node(name="TestDog", duplicate=False) + + person = await Node.init(db=db, schema=person_schema, branch=default_branch) + await person.new(db=db, name="Jack") + await person.save(db=db) + + dog = await Node.init(db=db, schema=dog_schema, branch=default_branch) + await dog.new(db=db, name="Rocky", breed="Labrador", owner=person) + await dog.save(db=db) + + await person.get_relationship("favorite_animal").update(db=db, data=dog) + await person.save(db=db) + + async def fail_node_load(*_args: object, **_kwargs: object) -> None: + raise AssertionError("the ID-only relationship unexpectedly used NodeDataLoader") + + monkeypatch.setattr( + "infrahub.graphql.resolvers.single_relationship.NodeDataLoader.load", + fail_node_load, + ) + + default_branch.update_schema_hash() + + concrete_peer_query = """ + query { + TestDog { + edges { + node { + id + owner { node { id } } + } + } + } + } + """ + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=concrete_peer_query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data == {"TestDog": {"edges": [{"node": {"id": dog.id, "owner": {"node": {"id": person.id}}}}]}} + assert gql_params.context.related_node_ids == {dog.id, person.id} + + generic_peer_query = """ + query { + TestPerson { + edges { + node { + id + favorite_animal { node { id } } + } + } + } + } + """ + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + result = await graphql( + schema=gql_params.schema, + source=generic_peer_query, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + + assert result.errors is None + assert result.data == { + "TestPerson": {"edges": [{"node": {"id": person.id, "favorite_animal": {"node": {"id": dog.id}}}}]} + } + assert gql_params.context.related_node_ids == {person.id, dog.id} diff --git a/changelog/+single-relationship-id-only-shortcut.changed.md b/changelog/+single-relationship-id-only-shortcut.changed.md new file mode 100644 index 00000000000..8c1d812ab57 --- /dev/null +++ b/changelog/+single-relationship-id-only-shortcut.changed.md @@ -0,0 +1 @@ +Improved the performance of GraphQL queries that only request the `id` of a cardinality-one relationship's peer. When no properties, metadata, or additional node fields are requested, the resolver now returns the peer ID already loaded on the parent instead of hydrating a full peer node, reducing database work on relationship-heavy queries. From e17fea54532539df43c32c14c10baff1fd2d15f4 Mon Sep 17 00:00:00 2001 From: Aaron McCarty Date: Thu, 30 Jul 2026 12:49:54 -0500 Subject: [PATCH 64/75] final uniqueness-constraint related touchups (#10082) * refactor(validators): inject the constraint determiner and merger into MergeConstraintValidator MergeConstraintValidator built its determiner and constraint-info merger inside validate() because both needed the candidate schema, which is only known per call. Turn the schema into an argument on their entry methods so the components can be constructed at the wiring layer and injected. - ConstraintValidatorDeterminer.get_constraints() takes schema_branch - UniquenessConstraintScoper.requires_validation()/affected_node_uuids() take schema_branch - UniquenessConstraintDeduplicator.deduplicate() takes schema_branch - ConstraintInfoMerger.merge() takes schema_branch - MergeConstraintValidator receives determiner + merger, no longer needs db Co-Authored-By: Claude Opus 5 (1M context) * fix test to run assertions * one integration test for node-scoped uniqueness violations --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/infrahub/core/branch/tasks.py | 10 +- backend/infrahub/core/merge/builder.py | 5 +- backend/infrahub/core/merge/constraints.py | 23 +-- .../core/validators/constraint_merge.py | 10 +- .../infrahub/core/validators/determiner.py | 54 +++--- .../validators/uniqueness/deduplicator.py | 25 +-- .../core/validators/uniqueness/scope.py | 27 +-- backend/infrahub/proposed_change/tasks.py | 10 +- .../constraint_validators/test_determiner.py | 72 ++++---- .../test_uniqueness_checker_node_scoped.py | 8 +- .../test_uniqueness_scope.py | 39 ++--- .../core/diff/test_coordinator_lock.py | 8 +- .../test_merge_uniqueness_field_scoping.py | 160 ++++++++++++++++++ .../test_schema_validator_rebase.py | 6 +- .../test_constraint_deduplicator.py | 36 ++-- .../core/validators/test_constraint_merge.py | 18 +- 16 files changed, 344 insertions(+), 167 deletions(-) create mode 100644 backend/tests/integration/merge/test_merge_uniqueness_field_scoping.py diff --git a/backend/infrahub/core/branch/tasks.py b/backend/infrahub/core/branch/tasks.py index ea585caddcb..9c480375164 100644 --- a/backend/infrahub/core/branch/tasks.py +++ b/backend/infrahub/core/branch/tasks.py @@ -192,10 +192,10 @@ async def rebase_branch(branch: str, context: InfrahubContext, send_events: bool ) candidate_schema = schema_analyzer.get_candidate_schema() - determiner = build_constraint_validator_determiner( - db=db, branch=user_branch, schema_branch=candidate_schema, at=rebase_at + determiner = build_constraint_validator_determiner(db=db, branch=user_branch, at=rebase_at) + data_diff_constraints = await determiner.get_constraints( + schema_branch=candidate_schema, node_diffs=node_diff_field_summaries ) - data_diff_constraints = await determiner.get_constraints(node_diffs=node_diff_field_summaries) # If there are some changes related to the schema between this branch and main, we need to # - Run all the validations to ensure everything is correct before rebasing the branch @@ -203,8 +203,8 @@ async def rebase_branch(branch: str, context: InfrahubContext, send_events: bool schema_diff_constraints: list[SchemaUpdateConstraintInfo] = [] if user_branch.has_schema_changes: schema_diff_constraints = await schema_analyzer.calculate_validations(target_schema=candidate_schema) - merger = build_constraint_info_merger(schema_branch=candidate_schema) - constraints = merger.merge(data_diff_constraints, schema_diff_constraints) + merger = build_constraint_info_merger() + constraints = merger.merge(candidate_schema, data_diff_constraints, schema_diff_constraints) if constraints: responses = await schema_validate_migrations( message=SchemaValidateMigrationData( diff --git a/backend/infrahub/core/merge/builder.py b/backend/infrahub/core/merge/builder.py index e7b228a8b02..84054c5ee4d 100644 --- a/backend/infrahub/core/merge/builder.py +++ b/backend/infrahub/core/merge/builder.py @@ -12,6 +12,8 @@ from infrahub.core.registry import registry from infrahub.core.rollback import GraphRollbacker from infrahub.core.schema.update_coordinator import SchemaUpdateCoordinator +from infrahub.core.validators.constraint_merge import build_constraint_info_merger +from infrahub.core.validators.determiner import build_constraint_validator_determiner from infrahub.core.validators.tasks import schema_validate_migrations from infrahub.dependencies.registry import get_component_registry from infrahub.workers.dependencies import get_cache, get_event_service, get_workflow @@ -65,9 +67,10 @@ async def build_branch_merge_orchestrator( schema_manager=registry.schema, ) constraint_validator = MergeConstraintValidator( - db=db, branch=source_branch, diff_repository=diff_repository, + determiner=build_constraint_validator_determiner(db=db, branch=source_branch), + constraint_info_merger=build_constraint_info_merger(), migration_validator=schema_validate_migrations, ) graph_merger = GraphMerger( diff --git a/backend/infrahub/core/merge/constraints.py b/backend/infrahub/core/merge/constraints.py index 36672344daf..2d19f83d93f 100644 --- a/backend/infrahub/core/merge/constraints.py +++ b/backend/infrahub/core/merge/constraints.py @@ -5,8 +5,6 @@ from infrahub.core.diff.model.diff import SchemaConflict from infrahub.core.diff.model.path import BranchTrackingId -from infrahub.core.validators.constraint_merge import build_constraint_info_merger -from infrahub.core.validators.determiner import build_constraint_validator_determiner from infrahub.core.validators.models.validate_migration import SchemaValidateMigrationData if TYPE_CHECKING: @@ -16,9 +14,10 @@ from infrahub.core.diff.repository.repository import DiffRepository from infrahub.core.models import SchemaUpdateConstraintInfo from infrahub.core.schema.schema_branch import SchemaBranch + from infrahub.core.validators.constraint_merge import ConstraintInfoMerger + from infrahub.core.validators.determiner import ConstraintValidatorDeterminer from infrahub.core.validators.model import SchemaViolation from infrahub.core.validators.models.validate_migration import SchemaValidatorPathResponseData - from infrahub.database import InfrahubDatabase MigrationValidator = Callable[[SchemaValidateMigrationData], Awaitable[list[SchemaValidatorPathResponseData]]] @@ -101,28 +100,30 @@ class MergeConstraintValidator: def __init__( self, - db: InfrahubDatabase, branch: Branch, diff_repository: DiffRepository, + determiner: ConstraintValidatorDeterminer, + constraint_info_merger: ConstraintInfoMerger, migration_validator: MigrationValidator, ) -> None: - self.db = db self.branch = branch self.diff_repository = diff_repository + self.determiner = determiner + self.constraint_info_merger = constraint_info_merger self.migration_validator = migration_validator async def validate( self, candidate_schema: SchemaBranch, schema_diff_constraints: list[SchemaUpdateConstraintInfo] ) -> MergeConstraintValidationResult: - determiner = build_constraint_validator_determiner( - db=self.db, branch=self.branch, schema_branch=candidate_schema - ) node_field_summaries = await self.diff_repository.get_node_field_summaries( diff_branch_name=self.branch.name, tracking_id=BranchTrackingId(name=self.branch.name) ) - data_diff_constraints = await determiner.get_constraints(node_diffs=node_field_summaries) - merger = build_constraint_info_merger(schema_branch=candidate_schema) - constraints = merger.merge(data_diff_constraints, schema_diff_constraints) + data_diff_constraints = await self.determiner.get_constraints( + schema_branch=candidate_schema, node_diffs=node_field_summaries + ) + constraints = self.constraint_info_merger.merge( + candidate_schema, data_diff_constraints, schema_diff_constraints + ) if not constraints: return MergeConstraintValidationResult() diff --git a/backend/infrahub/core/validators/constraint_merge.py b/backend/infrahub/core/validators/constraint_merge.py index 4c68ac68c12..4bd735ef46a 100644 --- a/backend/infrahub/core/validators/constraint_merge.py +++ b/backend/infrahub/core/validators/constraint_merge.py @@ -19,7 +19,9 @@ class ConstraintInfoMerger: def __init__(self, deduplicator: UniquenessConstraintDeduplicator) -> None: self.deduplicator = deduplicator - def merge(self, *constraint_lists: list[SchemaUpdateConstraintInfo]) -> list[SchemaUpdateConstraintInfo]: + def merge( + self, schema_branch: SchemaBranch, *constraint_lists: list[SchemaUpdateConstraintInfo] + ) -> list[SchemaUpdateConstraintInfo]: """Collapse the same constraint from multiple producers onto one entry. A constraint both broadened by a schema change (full population) and hit by a data change @@ -42,8 +44,8 @@ def merge(self, *constraint_lists: list[SchemaUpdateConstraintInfo]) -> list[Sch update={"node_uuids": sorted(set(existing.node_uuids) | set(constraint.node_uuids))} ) - return self.deduplicator.deduplicate(list(merged.values())) + return self.deduplicator.deduplicate(schema_branch=schema_branch, constraints=list(merged.values())) -def build_constraint_info_merger(schema_branch: SchemaBranch) -> ConstraintInfoMerger: - return ConstraintInfoMerger(deduplicator=UniquenessConstraintDeduplicator(schema_branch=schema_branch)) +def build_constraint_info_merger() -> ConstraintInfoMerger: + return ConstraintInfoMerger(deduplicator=UniquenessConstraintDeduplicator()) diff --git a/backend/infrahub/core/validators/determiner.py b/backend/infrahub/core/validators/determiner.py index 5092443daa3..ae0550a3fde 100644 --- a/backend/infrahub/core/validators/determiner.py +++ b/backend/infrahub/core/validators/determiner.py @@ -31,16 +31,14 @@ class ConstraintValidatorDeterminer: def __init__( self, - schema_branch: SchemaBranch, node_diff_index: NodeDiffIndex, uniqueness_scoper: UniquenessConstraintScoper, ) -> None: - self.schema_branch = schema_branch self.node_diff_index = node_diff_index self.uniqueness_scoper = uniqueness_scoper async def get_constraints( - self, node_diffs: list[NodeDiffFieldSummary], filter_invalid: bool = True + self, schema_branch: SchemaBranch, node_diffs: list[NodeDiffFieldSummary], filter_invalid: bool = True ) -> list[SchemaUpdateConstraintInfo]: self.node_diff_index.initialize(node_diffs) self.uniqueness_scoper.reset() @@ -48,10 +46,10 @@ async def get_constraints( if not node_diffs: return constraints - constraints.extend(await self._get_property_constraints_for_impacted_kinds()) + constraints.extend(await self._get_property_constraints_for_impacted_kinds(schema_branch=schema_branch)) for kind in self.node_diff_index.kinds: - schema = self._get_schema_or_none(kind=kind) + schema = self._get_schema_or_none(schema_branch=schema_branch, kind=kind) if schema is None: # a branch can hold data changes for a kind whose schema it also deletes LOG.info("Skipping constraints for kind absent from the schema", kind=kind) @@ -80,13 +78,13 @@ async def _get_constraints_for_one_schema(self, schema: MainSchemaTypes) -> list constraints.extend(await self._get_relationship_constraints_for_one_schema(schema=schema)) return constraints - def _get_schema_or_none(self, kind: str) -> MainSchemaTypes | None: + def _get_schema_or_none(self, schema_branch: SchemaBranch, kind: str) -> MainSchemaTypes | None: try: - return self.schema_branch.get(name=kind, duplicate=False) + return schema_branch.get(name=kind, duplicate=False) except SchemaNotFoundError: return None - def _get_impacted_kinds(self) -> set[str]: + def _get_impacted_kinds(self, schema_branch: SchemaBranch) -> set[str]: """Kinds with node-level property constraints that could be violated by the data diff. Includes the kinds present in the diff and the generics they inherit from, since a @@ -94,14 +92,16 @@ def _get_impacted_kinds(self) -> set[str]: """ kinds: set[str] = set() for kind in self.node_diff_index.kinds: - schema = self._get_schema_or_none(kind=kind) + schema = self._get_schema_or_none(schema_branch=schema_branch, kind=kind) if schema is None: continue kinds.add(kind) kinds.update(getattr(schema, "inherit_from", None) or []) return kinds - def _node_property_triggered_by_diff(self, schema: MainSchemaTypes, prop_name: str) -> bool: + def _node_property_triggered_by_diff( + self, schema_branch: SchemaBranch, schema: MainSchemaTypes, prop_name: str + ) -> bool: """Return True if the diff touches a field guarded by the node-level property `prop_name`. A node-level constraint may be defined on a kind without every data change to that kind @@ -109,31 +109,35 @@ def _node_property_triggered_by_diff(self, schema: MainSchemaTypes, prop_name: s properties default to emitting so a newly-added node-level constraint is never missed. """ if prop_name == "uniqueness_constraints": - return self.uniqueness_scoper.requires_validation(schema=schema) + return self.uniqueness_scoper.requires_validation(schema_branch=schema_branch, schema=schema) if prop_name in ("parent", "children"): return self.node_diff_index.has_relationship_diff(kind=schema.kind, name=prop_name) return True - async def _get_property_constraints_for_impacted_kinds(self) -> list[SchemaUpdateConstraintInfo]: - impacted_kinds = self._get_impacted_kinds() + async def _get_property_constraints_for_impacted_kinds( + self, schema_branch: SchemaBranch + ) -> list[SchemaUpdateConstraintInfo]: + impacted_kinds = self._get_impacted_kinds(schema_branch=schema_branch) schemas: list[MainSchemaTypes] = [] for kind in impacted_kinds: - schema = self._get_schema_or_none(kind=kind) + schema = self._get_schema_or_none(schema_branch=schema_branch, kind=kind) if schema is not None: schemas.append(schema) - for schema in self.schema_branch.get_all(duplicate=False).values(): + for schema in schema_branch.get_all(duplicate=False).values(): if schema.kind in impacted_kinds: continue - if self.uniqueness_scoper.requires_validation(schema=schema): + if self.uniqueness_scoper.requires_validation(schema_branch=schema_branch, schema=schema): schemas.append(schema) constraints: list[SchemaUpdateConstraintInfo] = [] for schema in schemas: - constraints.extend(await self._get_property_constraints_for_one_schema(schema=schema)) + constraints.extend( + await self._get_property_constraints_for_one_schema(schema_branch=schema_branch, schema=schema) + ) return constraints async def _get_property_constraints_for_one_schema( - self, schema: MainSchemaTypes + self, schema_branch: SchemaBranch, schema: MainSchemaTypes ) -> list[SchemaUpdateConstraintInfo]: constraints: list[SchemaUpdateConstraintInfo] = [] for prop_name, prop_field_info in schema.__class__.model_fields.items(): @@ -174,14 +178,18 @@ async def _get_property_constraints_for_one_schema( if not do_constraint_validation: continue - if not self._node_property_triggered_by_diff(schema=schema, prop_name=prop_name): + if not self._node_property_triggered_by_diff( + schema_branch=schema_branch, schema=schema, prop_name=prop_name + ): # the node-level constraint is defined on this kind, but no field it guards is in # the diff, so a data change cannot violate it continue node_uuids: list[str] | None = None if prop_name == "uniqueness_constraints": - node_uuids = await self.uniqueness_scoper.affected_node_uuids(schema=schema) + node_uuids = await self.uniqueness_scoper.affected_node_uuids( + schema_branch=schema_branch, schema=schema + ) constraints.append( SchemaUpdateConstraintInfo(constraint_name=constraint_name, path=schema_path, node_uuids=node_uuids) @@ -270,16 +278,12 @@ async def _get_constraints_for_one_field( def build_constraint_validator_determiner( db: InfrahubDatabase, branch: Branch, - schema_branch: SchemaBranch, at: Timestamp | str | None = None, ) -> ConstraintValidatorDeterminer: """Wire a determiner with its node-diff index and uniqueness scoper for a single operation.""" node_diff_index = NodeDiffIndex() uniqueness_scoper = UniquenessConstraintScoper( - schema_branch=schema_branch, dependent_resolver=UniquenessDependentResolver(db=db, branch=branch, at=at), node_diff_index=node_diff_index, ) - return ConstraintValidatorDeterminer( - schema_branch=schema_branch, node_diff_index=node_diff_index, uniqueness_scoper=uniqueness_scoper - ) + return ConstraintValidatorDeterminer(node_diff_index=node_diff_index, uniqueness_scoper=uniqueness_scoper) diff --git a/backend/infrahub/core/validators/uniqueness/deduplicator.py b/backend/infrahub/core/validators/uniqueness/deduplicator.py index 8a26463c13e..110a148311d 100644 --- a/backend/infrahub/core/validators/uniqueness/deduplicator.py +++ b/backend/infrahub/core/validators/uniqueness/deduplicator.py @@ -24,10 +24,9 @@ class UniquenessConstraintDeduplicator: (full-population) check is ever lost. """ - def __init__(self, schema_branch: SchemaBranch) -> None: - self.schema_branch = schema_branch - - def deduplicate(self, constraints: list[SchemaUpdateConstraintInfo]) -> list[SchemaUpdateConstraintInfo]: + def deduplicate( + self, schema_branch: SchemaBranch, constraints: list[SchemaUpdateConstraintInfo] + ) -> list[SchemaUpdateConstraintInfo]: uniqueness_infos = { constraint.path.schema_kind: constraint for constraint in constraints @@ -40,7 +39,9 @@ def deduplicate(self, constraints: list[SchemaUpdateConstraintInfo]) -> list[Sch redundant_kinds = { kind for kind, info in uniqueness_infos.items() - if self._is_covered_by_generic(kind=kind, info=info, uniqueness_infos=uniqueness_infos) + if self._is_covered_by_generic( + schema_branch=schema_branch, kind=kind, info=info, uniqueness_infos=uniqueness_infos + ) } if not redundant_kinds: return list(constraints) @@ -53,9 +54,13 @@ def deduplicate(self, constraints: list[SchemaUpdateConstraintInfo]) -> list[Sch ] def _is_covered_by_generic( - self, kind: str, info: SchemaUpdateConstraintInfo, uniqueness_infos: dict[str, SchemaUpdateConstraintInfo] + self, + schema_branch: SchemaBranch, + kind: str, + info: SchemaUpdateConstraintInfo, + uniqueness_infos: dict[str, SchemaUpdateConstraintInfo], ) -> bool: - schema = self._get_schema_or_none(kind=kind) + schema = self._get_schema_or_none(schema_branch=schema_branch, kind=kind) if schema is None or isinstance(schema, GenericSchema): # a generic is the coverer, never the covered return False @@ -70,7 +75,7 @@ def _is_covered_by_generic( continue if not self._scope_covers(node_info=info, generic_info=generic_info): continue - generic_schema = self._get_schema_or_none(kind=generic_kind) + generic_schema = self._get_schema_or_none(schema_branch=schema_branch, kind=generic_kind) if generic_schema is None: continue covered_groups |= self._constraint_groups(generic_schema) @@ -92,8 +97,8 @@ def _scope_covers(self, node_info: SchemaUpdateConstraintInfo, generic_info: Sch def _constraint_groups(self, schema: MainSchemaTypes) -> set[frozenset[str]]: return {frozenset(group) for group in schema.uniqueness_constraints or []} - def _get_schema_or_none(self, kind: str) -> MainSchemaTypes | None: + def _get_schema_or_none(self, schema_branch: SchemaBranch, kind: str) -> MainSchemaTypes | None: try: - return self.schema_branch.get(name=kind, duplicate=False) + return schema_branch.get(name=kind, duplicate=False) except SchemaNotFoundError: return None diff --git a/backend/infrahub/core/validators/uniqueness/scope.py b/backend/infrahub/core/validators/uniqueness/scope.py index 85e0733f031..9fe050391c6 100644 --- a/backend/infrahub/core/validators/uniqueness/scope.py +++ b/backend/infrahub/core/validators/uniqueness/scope.py @@ -72,26 +72,25 @@ class UniquenessConstraintScoper: def __init__( self, - schema_branch: SchemaBranch, dependent_resolver: UniquenessDependentResolverInterface, node_diff_index: NodeDiffIndex, ) -> None: - self.schema_branch = schema_branch self.dependent_resolver = dependent_resolver self.node_diff_index = node_diff_index # scopes are recomputed for the same kind across the trigger check and the uuid resolution; - # the cache is valid only for the node-diff index's current contents + # the cache is valid only for the node-diff index's current contents and the schema branch + # the scopes were computed against self._scope_cache: dict[str, UniquenessScopeForKind] = {} def reset(self) -> None: """Drop cached scopes so the next lookup recomputes against the current node-diff index.""" self._scope_cache = {} - def requires_validation(self, schema: MainSchemaTypes) -> bool: - return self._scope(schema=schema).requires_validation + def requires_validation(self, schema_branch: SchemaBranch, schema: MainSchemaTypes) -> bool: + return self._scope(schema_branch=schema_branch, schema=schema).requires_validation - async def affected_node_uuids(self, schema: MainSchemaTypes) -> list[str] | None: - scope = self._scope(schema=schema) + async def affected_node_uuids(self, schema_branch: SchemaBranch, schema: MainSchemaTypes) -> list[str] | None: + scope = self._scope(schema_branch=schema_branch, schema=schema) node_uuids = set(scope.object_uuids) for peer_change in scope.cross_kind_peer_changes: if not peer_change.changed_peer_uuids: @@ -135,14 +134,14 @@ def _uuids_for_field(self, kinds: set[str], field_name: str, is_relationship: bo uuids |= self.node_diff_index.get_uuids_for_attribute(kind=kind, name=field_name) return uuids - def _scope(self, schema: MainSchemaTypes) -> UniquenessScopeForKind: + def _scope(self, schema_branch: SchemaBranch, schema: MainSchemaTypes) -> UniquenessScopeForKind: cached = self._scope_cache.get(schema.kind) if cached is None: - cached = self._compute_scope(schema=schema) + cached = self._compute_scope(schema_branch=schema_branch, schema=schema) self._scope_cache[schema.kind] = cached return cached - def _compute_scope(self, schema: MainSchemaTypes) -> UniquenessScopeForKind: + def _compute_scope(self, schema_branch: SchemaBranch, schema: MainSchemaTypes) -> UniquenessScopeForKind: """Compute why and how a data change implicates `schema`'s uniqueness. Uniqueness spans single unique attributes and multi-field constraint groups, each element of @@ -151,7 +150,7 @@ def _compute_scope(self, schema: MainSchemaTypes) -> UniquenessScopeForKind: """ fragments = [ *self._unique_attribute_fragments(schema=schema), - *self._uniqueness_constraint_fragments(schema=schema), + *self._uniqueness_constraint_fragments(schema_branch=schema_branch, schema=schema), ] return UniquenessScopeForKind( requires_validation=bool(fragments), @@ -168,12 +167,14 @@ def _unique_attribute_fragments(self, schema: MainSchemaTypes) -> list[Uniquenes ) return [fragment for fragment in fragments if fragment is not None] - def _uniqueness_constraint_fragments(self, schema: MainSchemaTypes) -> list[UniquenessScopeFragment]: + def _uniqueness_constraint_fragments( + self, schema_branch: SchemaBranch, schema: MainSchemaTypes + ) -> list[UniquenessScopeFragment]: fragments: list[UniquenessScopeFragment] = [] for constraint_group in schema.uniqueness_constraints or []: for constraint_path in constraint_group: try: - schema_path = schema.parse_schema_path(path=constraint_path, schema=self.schema_branch) + schema_path = schema.parse_schema_path(path=constraint_path, schema=schema_branch) except AttributePathParsingError: LOG.warning(f"Cannot parse {schema.kind}.uniqueness_constraints element '{constraint_path}'") continue diff --git a/backend/infrahub/proposed_change/tasks.py b/backend/infrahub/proposed_change/tasks.py index 7ff4929a618..b52fd1cd7d3 100644 --- a/backend/infrahub/proposed_change/tasks.py +++ b/backend/infrahub/proposed_change/tasks.py @@ -552,8 +552,8 @@ async def run_proposed_change_schema_integrity_check(model: RequestProposedChang db=database, schema=candidate_schema, diff_summary=diff_summary, branch=source_branch ) constraints_from_schema_diff = validation_result.constraints - merger = build_constraint_info_merger(schema_branch=candidate_schema) - constraints = merger.merge(constraints_from_data_diff, constraints_from_schema_diff) + merger = build_constraint_info_merger() + constraints = merger.merge(candidate_schema, constraints_from_data_diff, constraints_from_schema_diff) if not constraints: return @@ -608,8 +608,10 @@ async def _get_proposed_change_schema_integrity_constraints( field_summary.add_attribute_node_uuid(name=element_name, node_uuid=node_id) async with db.start_session(read_only=True) as session_db: - determiner = build_constraint_validator_determiner(db=session_db, branch=branch, schema_branch=schema) - return await determiner.get_constraints(node_diffs=list(node_diff_field_summary_map.values())) + determiner = build_constraint_validator_determiner(db=session_db, branch=branch) + return await determiner.get_constraints( + schema_branch=schema, node_diffs=list(node_diff_field_summary_map.values()) + ) @flow(name="proposed-changed-repository-checks", flow_run_name="Process user defined checks") diff --git a/backend/tests/component/core/constraint_validators/test_determiner.py b/backend/tests/component/core/constraint_validators/test_determiner.py index cfe2cb6bda8..60848f29281 100644 --- a/backend/tests/component/core/constraint_validators/test_determiner.py +++ b/backend/tests/component/core/constraint_validators/test_determiner.py @@ -35,14 +35,10 @@ async def resolve( return set() -def _build_determiner(schema_branch: SchemaBranch) -> ConstraintValidatorDeterminer: +def _build_determiner() -> ConstraintValidatorDeterminer: node_diff_index = NodeDiffIndex() - scoper = UniquenessConstraintScoper( - schema_branch=schema_branch, dependent_resolver=_NoDependentsResolver(), node_diff_index=node_diff_index - ) - return ConstraintValidatorDeterminer( - schema_branch=schema_branch, node_diff_index=node_diff_index, uniqueness_scoper=scoper - ) + scoper = UniquenessConstraintScoper(dependent_resolver=_NoDependentsResolver(), node_diff_index=node_diff_index) + return ConstraintValidatorDeterminer(node_diff_index=node_diff_index, uniqueness_scoper=scoper) def node_constraint(kind: str, property_name: str) -> SchemaUpdateConstraintInfo: @@ -190,9 +186,9 @@ def person_cars_node_diff( class TestConstraintDeterminer: async def test_no_node_diffs(self, car_person_schema: SchemaBranch, default_branch: Branch) -> None: schema_branch = registry.schema.get_schema_branch(name=default_branch.name) - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() - constraints = await determiner.get_constraints(node_diffs=[]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[]) assert constraints == [] @@ -203,10 +199,10 @@ async def test_one_attribute_update_node_diff( person_name_node_diff: tuple[NodeDiffFieldSummary, set[SchemaUpdateConstraintInfo]], ) -> None: schema_branch = registry.schema.get_schema_branch(name=default_branch.name) - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() node_diff, constraint_info_set = person_name_node_diff - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) relevant_constraints = [ c @@ -224,10 +220,10 @@ async def test_many_relationship_update( person_cars_node_diff: tuple[NodeDiffFieldSummary, set[SchemaUpdateConstraintInfo]], ) -> None: schema_branch = registry.schema.get_schema_branch(name=default_branch.name) - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() node_diff, constraint_info_set = person_cars_node_diff - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) assert len(constraints) == len(constraint_info_set) assert constraint_info_set == set(constraints) @@ -245,7 +241,7 @@ async def test_node_property_constraints_included( name_attr_schema.parameters.max_length = 30 car_schema = schema_branch.get(name="TestCar", duplicate=False) car_schema.uniqueness_constraints = [["owner", "color__value"]] - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() node_diff, constraint_info_set = person_name_node_diff max_length_param_constraint_info = SchemaUpdateConstraintInfo( constraint_name=ConstraintIdentifier.ATTRIBUTE_PARAMETERS_MAX_LENGTH_UPDATE.value, @@ -259,7 +255,7 @@ async def test_node_property_constraints_included( constraint_info_set.add(node_uniqueness_constraint("TestPerson", node_uuids=[CHANGED_PERSON_UUID])) constraint_info_set.add(max_length_param_constraint_info) - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) assert set(constraints) == constraint_info_set @@ -272,14 +268,14 @@ async def test_uniqueness_constraint_on_peer_attribute_included( schema_branch = registry.schema.get_schema_branch(name=default_branch.name) car_schema = schema_branch.get(name="TestCar", duplicate=False) car_schema.uniqueness_constraints = [["owner__name", "color__value"]] - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() node_diff, constraint_info_set = person_name_node_diff constraint_info_set.add(node_uniqueness_constraint("TestPerson", node_uuids=[CHANGED_PERSON_UUID])) # TestCar's constraint reads the name attribute of the related TestPerson, so a TestPerson # data change can violate it even though TestCar itself has no diff. constraint_info_set.add(node_uniqueness_constraint("TestCar")) - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) assert set(constraints) == constraint_info_set @@ -291,7 +287,7 @@ async def test_uniqueness_not_triggered_by_unrelated_field( schema_branch = registry.schema.get_schema_branch(name=default_branch.name) generic_schema = schema_branch.get(name="TestCar", duplicate=False) generic_schema.uniqueness_constraints = [["name__value"]] - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() node_diff = NodeDiffFieldSummary(kind="TestElectricCar", attribute_node_uuids={"nbr_engine": set()}) # nbr_engine participates in no uniqueness path, so the uniqueness check must not be # triggered on the implementation or on its generic; only the nbr_engine field constraints remain @@ -301,7 +297,7 @@ async def test_uniqueness_not_triggered_by_unrelated_field( attribute_constraint("TestElectricCar", "nbr_engine", "unique"), } - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) assert set(constraints) == constraint_info_set @@ -313,7 +309,7 @@ async def test_generic_uniqueness_triggered_by_inherited_field( schema_branch = registry.schema.get_schema_branch(name=default_branch.name) generic_schema = schema_branch.get(name="TestCar", duplicate=False) generic_schema.uniqueness_constraints = [["name__value"]] - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() # `name` is inherited from the generic; a generic-level uniqueness check spans every # implementing node, so changing name on an implementation must trigger the check on the # generic (TestCar) as well as on the implementation (TestElectricCar) @@ -321,7 +317,7 @@ async def test_generic_uniqueness_triggered_by_inherited_field( kind="TestElectricCar", attribute_node_uuids={"name": {CHANGED_ELECTRIC_CAR_UUID}} ) - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) # both the generic and the implementation checks are scoped to the changed implementation node expected = { @@ -344,10 +340,10 @@ async def test_uniqueness_triggered_by_generic_peer_implementation( # reading the peer's `name` must fire when an implementation of that generic (LocationSite) # changes `name`, even though the peer kind named in the path (LocationGeneric) has no diff thing_schema.uniqueness_constraints = [["location__name"]] - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() node_diff = NodeDiffFieldSummary(kind="LocationSite", attribute_node_uuids={"name": set()}) - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) assert node_uniqueness_constraint("TestThing") in set(constraints) @@ -359,13 +355,13 @@ async def test_uniqueness_not_triggered_by_unrelated_peer_attribute( schema_branch = registry.schema.get_schema_branch(name=default_branch.name) car_schema = schema_branch.get(name="TestCar", duplicate=False) car_schema.uniqueness_constraints = [["owner__name", "color__value"]] - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() # TestCar's uniqueness constraints read TestPerson.name; a change to an unrelated # TestPerson attribute, height, does not participate, so neither kind's uniqueness # check should trigger node_diff = NodeDiffFieldSummary(kind="TestPerson", attribute_node_uuids={"height": set()}) - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) constraint_set = set(constraints) assert node_uniqueness_constraint("TestCar") not in constraint_set @@ -378,12 +374,13 @@ async def test_kind_missing_from_schema_is_skipped( person_name_node_diff: tuple[NodeDiffFieldSummary, set[SchemaUpdateConstraintInfo]], ) -> None: schema_branch = registry.schema.get_schema_branch(name=default_branch.name) - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() node_diff, constraint_info_set = person_name_node_diff constraint_info_set.add(node_uniqueness_constraint("TestPerson", node_uuids=[CHANGED_PERSON_UUID])) constraints = await determiner.get_constraints( - node_diffs=[NodeDiffFieldSummary(kind="TestDeleted", attribute_node_uuids={"name": set()}), node_diff] + schema_branch=schema_branch, + node_diffs=[NodeDiffFieldSummary(kind="TestDeleted", attribute_node_uuids={"name": set()}), node_diff], ) # TestDeleted is absent from the schema, so it contributes nothing; only TestPerson remains @@ -398,21 +395,22 @@ async def test_internal_schema_kinds_only_when_in_diff( schema_branch = registry.schema.register_schema( schema=SchemaRoot(**internal_schema), branch=default_branch.name ) - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() node_diff, constraint_info_set = person_name_node_diff constraint_info_set.add(node_uniqueness_constraint("TestPerson", node_uuids=[CHANGED_PERSON_UUID])) - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) # internal schema kinds are absent from the diff, so none contribute constraints assert set(constraints) == constraint_info_set internal_kinds = {"SchemaNode", "SchemaGeneric", "SchemaAttribute", "SchemaRelationship"} - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() constraints = await determiner.get_constraints( + schema_branch=schema_branch, node_diffs=[ node_diff, *(NodeDiffFieldSummary(kind=kind, attribute_node_uuids={"name": set()}) for kind in internal_kinds), - ] + ], ) # once an internal schema kind is in the diff, its uniqueness constraint must be validated # (this is what catches duplicate schema elements when a branch is merged) @@ -426,7 +424,7 @@ async def test_hierarchy_constraints_selected_for_both_endpoint_kinds( default_branch: Branch, ) -> None: schema_branch = registry.schema.get_schema_branch(name=default_branch.name) - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() # A hierarchy edge change surfaces on both endpoints: the child (LocationRack) records its # `parent` change and the parent (LocationSite) records its `children` change. Each endpoint # emits only the hierarchy constraint whose relationship actually changed there, and no @@ -442,7 +440,7 @@ async def test_hierarchy_constraints_selected_for_both_endpoint_kinds( *(relationship_constraint("LocationSite", "children", p) for p in RELATIONSHIP_PROPERTIES), } - constraints = await determiner.get_constraints(node_diffs=node_diffs) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=node_diffs) assert set(constraints) == expected @@ -455,13 +453,13 @@ async def test_unparseable_uniqueness_constraint_element_is_skipped_and_logged( schema_branch = registry.schema.get_schema_branch(name=default_branch.name) person_schema = schema_branch.get(name="TestPerson", duplicate=False) person_schema.uniqueness_constraints = [["does_not_exist__value"]] - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() # `height` is not a unique attribute, so evaluating uniqueness must fall through to parsing # the (unparseable) constraint group rather than short-circuiting on a unique attribute. node_diff = NodeDiffFieldSummary(kind="TestPerson", attribute_node_uuids={"height": set()}) with caplog.at_level(logging.WARNING): - constraints = await determiner.get_constraints(node_diffs=[node_diff]) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=[node_diff]) assert "Cannot parse TestPerson.uniqueness_constraints element 'does_not_exist__value'" in caplog.text # the unparseable element is skipped in isolation, so no uniqueness check is emitted for the kind @@ -473,12 +471,12 @@ async def test_hierarchy_constraint_scoped_to_changed_relationship( default_branch: Branch, ) -> None: schema_branch = registry.schema.get_schema_branch(name=default_branch.name) - determiner = _build_determiner(schema_branch=schema_branch) + determiner = _build_determiner() # Re-parenting a rack changes only its `parent` relationship, so only the parent hierarchy # constraint may be emitted for that kind, never the children one. node_diffs = [NodeDiffFieldSummary(kind="LocationSite", relationship_node_uuids={"parent": set()})] - constraints = await determiner.get_constraints(node_diffs=node_diffs) + constraints = await determiner.get_constraints(schema_branch=schema_branch, node_diffs=node_diffs) constraint_set = set(constraints) assert node_constraint("LocationSite", "parent") in constraint_set diff --git a/backend/tests/component/core/constraint_validators/test_uniqueness_checker_node_scoped.py b/backend/tests/component/core/constraint_validators/test_uniqueness_checker_node_scoped.py index 709d8b156f8..3f4c0aa86e9 100644 --- a/backend/tests/component/core/constraint_validators/test_uniqueness_checker_node_scoped.py +++ b/backend/tests/component/core/constraint_validators/test_uniqueness_checker_node_scoped.py @@ -155,12 +155,12 @@ async def test_cross_kind_peer_change_resolves_and_detects_end_to_end( registry.schema.register_schema(schema=SchemaRoot(nodes=[car_schema]), branch=branch.name) synced_schema = registry.schema.get_node_schema(name="TestCar", branch=branch, duplicate=False) - determiner = build_constraint_validator_determiner( - db=db, branch=branch, schema_branch=registry.schema.get_schema_branch(name=branch.name) - ) + determiner = build_constraint_validator_determiner(db=db, branch=branch) person_change = NodeDiffFieldSummary(kind="TestPerson", attribute_node_uuids={"height": {person_john_main.id}}) - constraints = await determiner.get_constraints(node_diffs=[person_change]) + constraints = await determiner.get_constraints( + schema_branch=registry.schema.get_schema_branch(name=branch.name), node_diffs=[person_change] + ) car_constraint = next( c diff --git a/backend/tests/component/core/constraint_validators/test_uniqueness_scope.py b/backend/tests/component/core/constraint_validators/test_uniqueness_scope.py index f575554d0bb..c10f2c3728e 100644 --- a/backend/tests/component/core/constraint_validators/test_uniqueness_scope.py +++ b/backend/tests/component/core/constraint_validators/test_uniqueness_scope.py @@ -26,12 +26,10 @@ async def resolve( return set(self.dependents) -def _scoper(schema_branch: SchemaBranch, resolver: _RecordingResolver, node_diffs: list[NodeDiffFieldSummary]): +def _scoper(resolver: _RecordingResolver, node_diffs: list[NodeDiffFieldSummary]): node_diff_index = NodeDiffIndex() node_diff_index.initialize(node_diffs) - return UniquenessConstraintScoper( - schema_branch=schema_branch, dependent_resolver=resolver, node_diff_index=node_diff_index - ) + return UniquenessConstraintScoper(dependent_resolver=resolver, node_diff_index=node_diff_index) class TestUniquenessConstraintScoper: @@ -40,14 +38,16 @@ async def test_same_kind_change_scopes_to_changed_nodes( ) -> None: schema_branch = registry.schema.get_schema_branch(name=default_branch.name) scoper = _scoper( - schema_branch, _RecordingResolver(dependents=set()), [NodeDiffFieldSummary(kind="TestPerson", attribute_node_uuids={"name": {"person-1", "person-2"}})], ) person_schema = schema_branch.get(name="TestPerson") - assert scoper.requires_validation(schema=person_schema) is True - assert await scoper.affected_node_uuids(schema=person_schema) == ["person-1", "person-2"] + assert scoper.requires_validation(schema_branch=schema_branch, schema=person_schema) is True + assert await scoper.affected_node_uuids(schema_branch=schema_branch, schema=person_schema) == [ + "person-1", + "person-2", + ] async def test_scopes_only_nodes_that_changed_the_unique_field( self, car_person_schema: SchemaBranch, default_branch: Branch @@ -55,7 +55,6 @@ async def test_scopes_only_nodes_that_changed_the_unique_field( schema_branch = registry.schema.get_schema_branch(name=default_branch.name) # person-1 changed the unique "name"; person-2 only changed the non-unique "height" scoper = _scoper( - schema_branch, _RecordingResolver(dependents=set()), [ NodeDiffFieldSummary( @@ -66,23 +65,22 @@ async def test_scopes_only_nodes_that_changed_the_unique_field( ) person_schema = schema_branch.get(name="TestPerson") - assert scoper.requires_validation(schema=person_schema) is True + assert scoper.requires_validation(schema_branch=schema_branch, schema=person_schema) is True # only person-1 is scoped; person-2's change does not participate in uniqueness - assert await scoper.affected_node_uuids(schema=person_schema) == ["person-1"] + assert await scoper.affected_node_uuids(schema_branch=schema_branch, schema=person_schema) == ["person-1"] async def test_triggered_without_node_uuids_falls_back_to_full_scan( self, car_person_schema: SchemaBranch, default_branch: Branch ) -> None: schema_branch = registry.schema.get_schema_branch(name=default_branch.name) scoper = _scoper( - schema_branch, _RecordingResolver(dependents=set()), [NodeDiffFieldSummary(kind="TestPerson", attribute_node_uuids={"name": set()})], ) person_schema = schema_branch.get(name="TestPerson") - assert scoper.requires_validation(schema=person_schema) is True - assert await scoper.affected_node_uuids(schema=person_schema) is None + assert scoper.requires_validation(schema_branch=schema_branch, schema=person_schema) is True + assert await scoper.affected_node_uuids(schema_branch=schema_branch, schema=person_schema) is None async def test_unrelated_field_change_does_not_trigger( self, car_person_schema: SchemaBranch, default_branch: Branch @@ -90,14 +88,13 @@ async def test_unrelated_field_change_does_not_trigger( schema_branch = registry.schema.get_schema_branch(name=default_branch.name) # height is not part of any TestPerson uniqueness constraint scoper = _scoper( - schema_branch, _RecordingResolver(dependents=set()), [NodeDiffFieldSummary(kind="TestPerson", attribute_node_uuids={"height": {"person-1"}})], ) person_schema = schema_branch.get(name="TestPerson") - assert scoper.requires_validation(schema=person_schema) is False - assert await scoper.affected_node_uuids(schema=person_schema) is None + assert scoper.requires_validation(schema_branch=schema_branch, schema=person_schema) is False + assert await scoper.affected_node_uuids(schema_branch=schema_branch, schema=person_schema) is None async def test_cross_kind_peer_change_resolves_dependents( self, car_person_schema: SchemaBranch, default_branch: Branch @@ -111,14 +108,13 @@ async def test_cross_kind_peer_change_resolves_dependents( resolver = _RecordingResolver(dependents={"car-1", "car-2"}) # a change to the peer kind's name, with no change to TestCar itself scoper = _scoper( - schema_branch, resolver, [NodeDiffFieldSummary(kind="TestPerson", attribute_node_uuids={"name": {"person-1"}})], ) car_schema = schema_branch.get(name="TestCar") - assert scoper.requires_validation(schema=car_schema) is True - assert await scoper.affected_node_uuids(schema=car_schema) == ["car-1", "car-2"] + assert scoper.requires_validation(schema_branch=schema_branch, schema=car_schema) is True + assert await scoper.affected_node_uuids(schema_branch=schema_branch, schema=car_schema) == ["car-1", "car-2"] # the peer change is routed to the resolver as a single call carrying the changed peer uuids owner_relationship = car_schema.get_relationship(name="owner") assert resolver.calls == [ @@ -137,12 +133,11 @@ async def test_cross_kind_without_known_peer_uuids_falls_back_to_full_scan( resolver = _RecordingResolver(dependents={"car-1"}) # the peer changed but its node uuids are unknown, so the dependents cannot be resolved scoper = _scoper( - schema_branch, resolver, [NodeDiffFieldSummary(kind="TestPerson", attribute_node_uuids={"name": set()})], ) car_schema = schema_branch.get(name="TestCar") - assert scoper.requires_validation(schema=car_schema) is True - assert await scoper.affected_node_uuids(schema=car_schema) is None + assert scoper.requires_validation(schema_branch=schema_branch, schema=car_schema) is True + assert await scoper.affected_node_uuids(schema_branch=schema_branch, schema=car_schema) is None assert not resolver.calls # resolver is never called when the peer uuids are unknown diff --git a/backend/tests/component/core/diff/test_coordinator_lock.py b/backend/tests/component/core/diff/test_coordinator_lock.py index 81c54e5fa08..c7e875f3b15 100644 --- a/backend/tests/component/core/diff/test_coordinator_lock.py +++ b/backend/tests/component/core/diff/test_coordinator_lock.py @@ -20,6 +20,8 @@ from infrahub.core.rollback import GraphRollbacker from infrahub.core.schema.schema_branch import SchemaBranch from infrahub.core.timestamp import Timestamp +from infrahub.core.validators.constraint_merge import build_constraint_info_merger +from infrahub.core.validators.determiner import build_constraint_validator_determiner from infrahub.core.validators.tasks import schema_validate_migrations from infrahub.database import InfrahubDatabase, get_db from infrahub.dependencies.registry import get_component_registry @@ -209,9 +211,10 @@ async def test_diff_update_blocks_merge( schema_manager=registry.schema, ), constraint_validator=MergeConstraintValidator( - db=db, branch=diff_branch, diff_repository=diff_repository, + determiner=build_constraint_validator_determiner(db=db, branch=diff_branch), + constraint_info_merger=build_constraint_info_merger(), migration_validator=schema_validate_migrations, ), ) @@ -267,9 +270,10 @@ async def test_merge_blocks_diff_update( schema_manager=registry.schema, ), constraint_validator=MergeConstraintValidator( - db=db2, branch=diff_branch, diff_repository=diff_repository_2, + determiner=build_constraint_validator_determiner(db=db2, branch=diff_branch), + constraint_info_merger=build_constraint_info_merger(), migration_validator=schema_validate_migrations, ), ) diff --git a/backend/tests/integration/merge/test_merge_uniqueness_field_scoping.py b/backend/tests/integration/merge/test_merge_uniqueness_field_scoping.py new file mode 100644 index 00000000000..a3ad7599ed4 --- /dev/null +++ b/backend/tests/integration/merge/test_merge_uniqueness_field_scoping.py @@ -0,0 +1,160 @@ +"""Merge-time uniqueness validation when only some changed fields participate in the constraint. + +The check is scoped to the nodes whose changed field participates in the kind's uniqueness, so a +diff mixing a participating and a non-participating change must still reach the participating one. +Both branches here change data only, keeping the node-scoped path in play — a branch that also +carries a schema change is validated against the whole population instead. +""" + +from __future__ import annotations + +import copy +from typing import TYPE_CHECKING + +import pytest +from infrahub_sdk.exceptions import GraphQLError + +from infrahub.core.manager import NodeManager +from infrahub.core.node import Node +from tests.constants import TestKind +from tests.helpers.schema import CAR_SCHEMA, load_schema +from tests.helpers.test_app import TestInfrahubApp + +if TYPE_CHECKING: + from infrahub_sdk import InfrahubClient + + from infrahub.core.branch import Branch + from infrahub.core.schema import SchemaRoot + from infrahub.database import InfrahubDatabase + from tests.adapters.message_bus import BusSimulator + +BLOCKED_BRANCH = "uniqueness_scoping_relationship" +CLEAN_BRANCH = "uniqueness_scoping_unrelated" + +BRANCH_MERGE_MUTATION = """ +mutation($branch: String!) { + BranchMerge(data: { name: $branch }) { + ok + } +} +""" + + +async def _get_car(db: InfrahubDatabase, car_id: str, branch: Branch | str) -> Node: + return await NodeManager.get_one(db=db, id=car_id, branch=branch, kind=TestKind.CAR, raise_on_error=True) + + +class TestMergeUniquenessFieldScoping(TestInfrahubApp): + @pytest.fixture(scope="class") + def car_schema_unique_owner_name(self) -> SchemaRoot: + """Cars made unique by their owner together with their name. + + "owner" is a relationship and "name__value" an attribute, so a change to either half + implicates the constraint while "color"/"description" changes cannot. + """ + schema = copy.deepcopy(CAR_SCHEMA) + car = next(node for node in schema.nodes if node.kind == TestKind.CAR) + car.uniqueness_constraints = [["owner", "name__value"]] + return schema + + @pytest.fixture(scope="class") + async def initial_dataset( + self, + db: InfrahubDatabase, + initialize_registry: None, + client: InfrahubClient, + bus_simulator: BusSimulator, + prefect_test_fixture: None, + car_schema_unique_owner_name: SchemaRoot, + ) -> dict[str, str]: + """Two cars named "civic" kept distinct only by their owner, plus an unrelated "accord".""" + await load_schema(db, schema=car_schema_unique_owner_name) + + john = await Node.init(schema=TestKind.PERSON, db=db) + await john.new(db=db, name="John", height=175) + await john.save(db=db) + jane = await Node.init(schema=TestKind.PERSON, db=db) + await jane.new(db=db, name="Jane", height=165) + await jane.save(db=db) + honda = await Node.init(schema=TestKind.MANUFACTURER, db=db) + await honda.new(db=db, name="honda") + await honda.save(db=db) + + civic_john = await Node.init(schema=TestKind.CAR, db=db) + await civic_john.new(db=db, name="civic", color="blue", owner=john, manufacturer=honda) + await civic_john.save(db=db) + civic_jane = await Node.init(schema=TestKind.CAR, db=db) + await civic_jane.new(db=db, name="civic", color="red", owner=jane, manufacturer=honda) + await civic_jane.save(db=db) + accord_john = await Node.init(schema=TestKind.CAR, db=db) + await accord_john.new(db=db, name="accord", color="green", owner=john, manufacturer=honda) + await accord_john.save(db=db) + + return { + "john": john.id, + "jane": jane.id, + "civic_john": civic_john.id, + "civic_jane": civic_jane.id, + "accord_john": accord_john.id, + } + + async def test_participating_relationship_change_blocks_merge( + self, + db: InfrahubDatabase, + default_branch: Branch, + initial_dataset: dict[str, str], + client: InfrahubClient, + ) -> None: + """A re-owned car collides on (owner, name) even though no node changed the name attribute. + + The branch also changes another car's color, a field no constraint group reads, so the + participating change is the relationship one and it is the only reason to validate. + """ + await client.branch.create(branch_name=BLOCKED_BRANCH) + + civic_jane_branch = await _get_car(db=db, car_id=initial_dataset["civic_jane"], branch=BLOCKED_BRANCH) + await civic_jane_branch.get_relationship("owner").update(db=db, data=initial_dataset["john"]) + await civic_jane_branch.save(db=db) + accord_branch = await _get_car(db=db, car_id=initial_dataset["accord_john"], branch=BLOCKED_BRANCH) + accord_branch.get_attribute("color").value = "black" + await accord_branch.save(db=db) + + with pytest.raises(GraphQLError) as exc: + await client.execute_graphql(query=BRANCH_MERGE_MUTATION, variables={"branch": BLOCKED_BRANCH}) + + message = exc.value.message + civic_john_branch = await _get_car(db=db, car_id=initial_dataset["civic_john"], branch=BLOCKED_BRANCH) + for car in (civic_jane_branch, civic_john_branch): + display_label = await car.get_display_label(db=db) + assert ( + f"Node-level 'uniqueness_constraints' constraint violation on schema '{TestKind.CAR}'." + f" Node ({display_label}) is not compliant." + ) in message + # the car whose only change is a non-participating field is never implicated + assert await accord_branch.get_display_label(db=db) not in message + + # main keeps both owners, so it never holds two cars named "civic" with the same owner + civic_jane_main = await _get_car(db=db, car_id=initial_dataset["civic_jane"], branch=default_branch) + owner_main = await civic_jane_main.get_relationship("owner").get_peer(db=db, raise_on_error=True) + assert owner_main.id == initial_dataset["jane"] + accord_main = await _get_car(db=db, car_id=initial_dataset["accord_john"], branch=default_branch) + assert accord_main.get_attribute("color").value == "green" + + async def test_non_participating_change_alone_merges( + self, + db: InfrahubDatabase, + default_branch: Branch, + initial_dataset: dict[str, str], + client: InfrahubClient, + ) -> None: + """A branch touching only a field outside every constraint group merges without a violation.""" + await client.branch.create(branch_name=CLEAN_BRANCH) + + civic_john_branch = await _get_car(db=db, car_id=initial_dataset["civic_john"], branch=CLEAN_BRANCH) + civic_john_branch.get_attribute("description").value = "the blue one" + await civic_john_branch.save(db=db) + + await client.execute_graphql(query=BRANCH_MERGE_MUTATION, variables={"branch": CLEAN_BRANCH}) + + civic_john_main = await _get_car(db=db, car_id=initial_dataset["civic_john"], branch=default_branch) + assert civic_john_main.get_attribute("description").value == "the blue one" diff --git a/backend/tests/integration/schema_lifecycle/test_schema_validator_rebase.py b/backend/tests/integration/schema_lifecycle/test_schema_validator_rebase.py index bbdf44decff..b9e98c9a349 100644 --- a/backend/tests/integration/schema_lifecycle/test_schema_validator_rebase.py +++ b/backend/tests/integration/schema_lifecycle/test_schema_validator_rebase.py @@ -186,9 +186,9 @@ async def test_step_02_node_unique_rebase_failure( with pytest.raises(GraphQLError) as exc: await client.branch.rebase(branch_name=branch_2.name) - assert initial_dataset["accord"] in exc.value.message - assert another_civic.id in exc.value.message - assert "node.uniqueness_constraints.update" in exc.value.message + assert initial_dataset["accord"] in exc.value.message + assert another_civic.id in exc.value.message + assert "node.uniqueness_constraints.update" in exc.value.message async def test_final_validate(self, db: InfrahubDatabase) -> None: await verify_no_duplicate_relationships(db=db) diff --git a/backend/tests/unit/core/validators/test_constraint_deduplicator.py b/backend/tests/unit/core/validators/test_constraint_deduplicator.py index f02604e087f..ab12d1d4ec0 100644 --- a/backend/tests/unit/core/validators/test_constraint_deduplicator.py +++ b/backend/tests/unit/core/validators/test_constraint_deduplicator.py @@ -77,91 +77,91 @@ def _remaining_uniqueness_kinds(constraints: list[SchemaUpdateConstraintInfo]) - class TestUniquenessConstraintDeduplicator: def test_inherited_node_dropped_when_generic_covers_it_full_population(self) -> None: - deduplicator = UniquenessConstraintDeduplicator(schema_branch=_schema_branch()) + deduplicator = UniquenessConstraintDeduplicator() constraints = [_uniqueness_info("TestCar"), _uniqueness_info("TestElectricCar")] - result = deduplicator.deduplicate(constraints) + result = deduplicator.deduplicate(schema_branch=_schema_branch(), constraints=constraints) assert _remaining_uniqueness_kinds(result) == {"TestCar"} def test_all_implementers_dropped_when_generic_covers_them(self) -> None: # the generic's scope is the union of the implementers' changed nodes - deduplicator = UniquenessConstraintDeduplicator(schema_branch=_schema_branch()) + deduplicator = UniquenessConstraintDeduplicator() constraints = [ _uniqueness_info("TestCar", node_uuids=["e1", "g1"]), _uniqueness_info("TestElectricCar", node_uuids=["e1"]), _uniqueness_info("TestGazCar", node_uuids=["g1"]), ] - result = deduplicator.deduplicate(constraints) + result = deduplicator.deduplicate(schema_branch=_schema_branch(), constraints=constraints) assert _remaining_uniqueness_kinds(result) == {"TestCar"} def test_node_with_own_group_is_kept(self) -> None: # SpecialCar has a `special` group the generic does not cover, so it cannot be dropped - deduplicator = UniquenessConstraintDeduplicator(schema_branch=_schema_branch()) + deduplicator = UniquenessConstraintDeduplicator() constraints = [_uniqueness_info("TestCar"), _uniqueness_info("TestSpecialCar")] - result = deduplicator.deduplicate(constraints) + result = deduplicator.deduplicate(schema_branch=_schema_branch(), constraints=constraints) assert _remaining_uniqueness_kinds(result) == {"TestCar", "TestSpecialCar"} def test_full_population_node_not_dropped_for_scoped_generic(self) -> None: # dropping a full-population node for a scoped generic would silently narrow the check - deduplicator = UniquenessConstraintDeduplicator(schema_branch=_schema_branch()) + deduplicator = UniquenessConstraintDeduplicator() constraints = [ _uniqueness_info("TestCar", node_uuids=["e1"]), _uniqueness_info("TestElectricCar", node_uuids=None), ] - result = deduplicator.deduplicate(constraints) + result = deduplicator.deduplicate(schema_branch=_schema_branch(), constraints=constraints) assert _remaining_uniqueness_kinds(result) == {"TestCar", "TestElectricCar"} def test_node_not_dropped_when_generic_scope_does_not_cover_it(self) -> None: - deduplicator = UniquenessConstraintDeduplicator(schema_branch=_schema_branch()) + deduplicator = UniquenessConstraintDeduplicator() constraints = [ _uniqueness_info("TestCar", node_uuids=["other"]), _uniqueness_info("TestElectricCar", node_uuids=["e1"]), ] - result = deduplicator.deduplicate(constraints) + result = deduplicator.deduplicate(schema_branch=_schema_branch(), constraints=constraints) assert _remaining_uniqueness_kinds(result) == {"TestCar", "TestElectricCar"} def test_scoped_node_dropped_when_generic_scope_is_superset(self) -> None: - deduplicator = UniquenessConstraintDeduplicator(schema_branch=_schema_branch()) + deduplicator = UniquenessConstraintDeduplicator() constraints = [ _uniqueness_info("TestCar", node_uuids=["e1", "e2", "g1"]), _uniqueness_info("TestElectricCar", node_uuids=["e1", "e2"]), ] - result = deduplicator.deduplicate(constraints) + result = deduplicator.deduplicate(schema_branch=_schema_branch(), constraints=constraints) assert _remaining_uniqueness_kinds(result) == {"TestCar"} def test_node_kept_when_generic_absent_from_set(self) -> None: - deduplicator = UniquenessConstraintDeduplicator(schema_branch=_schema_branch()) + deduplicator = UniquenessConstraintDeduplicator() constraints = [_uniqueness_info("TestElectricCar")] - result = deduplicator.deduplicate(constraints) + result = deduplicator.deduplicate(schema_branch=_schema_branch(), constraints=constraints) assert _remaining_uniqueness_kinds(result) == {"TestElectricCar"} def test_standalone_node_is_kept(self) -> None: - deduplicator = UniquenessConstraintDeduplicator(schema_branch=_schema_branch()) + deduplicator = UniquenessConstraintDeduplicator() constraints = [_uniqueness_info("TestCar"), _uniqueness_info("TestPerson")] - result = deduplicator.deduplicate(constraints) + result = deduplicator.deduplicate(schema_branch=_schema_branch(), constraints=constraints) assert _remaining_uniqueness_kinds(result) == {"TestCar", "TestPerson"} def test_non_uniqueness_constraints_pass_through(self) -> None: - deduplicator = UniquenessConstraintDeduplicator(schema_branch=_schema_branch()) + deduplicator = UniquenessConstraintDeduplicator() attribute = _attribute_info("TestElectricCar") constraints = [_uniqueness_info("TestCar"), _uniqueness_info("TestElectricCar"), attribute] - result = deduplicator.deduplicate(constraints) + result = deduplicator.deduplicate(schema_branch=_schema_branch(), constraints=constraints) assert _remaining_uniqueness_kinds(result) == {"TestCar"} assert attribute in result diff --git a/backend/tests/unit/core/validators/test_constraint_merge.py b/backend/tests/unit/core/validators/test_constraint_merge.py index 269a4eefb27..ac1aca1fa6b 100644 --- a/backend/tests/unit/core/validators/test_constraint_merge.py +++ b/backend/tests/unit/core/validators/test_constraint_merge.py @@ -46,27 +46,28 @@ class TestConstraintInfoMergerPrecedence: def test_full_scan_wins_over_node_scoped(self) -> None: # a constraint both broadened (schema diff, full scan) and data-changed collapses to a full scan - merger = build_constraint_info_merger(schema_branch=_schema_branch()) + merger = build_constraint_info_merger() data_diff = [_uniqueness_info("TestCar", node_uuids=["a", "b"])] schema_diff = [_uniqueness_info("TestCar", node_uuids=None)] - result = merger.merge(data_diff, schema_diff) + result = merger.merge(_schema_branch(), data_diff, schema_diff) assert result == [_uniqueness_info("TestCar", node_uuids=None)] def test_full_scan_wins_regardless_of_order(self) -> None: - merger = build_constraint_info_merger(schema_branch=_schema_branch()) + merger = build_constraint_info_merger() schema_diff = [_uniqueness_info("TestCar", node_uuids=None)] data_diff = [_uniqueness_info("TestCar", node_uuids=["a", "b"])] - result = merger.merge(schema_diff, data_diff) + result = merger.merge(_schema_branch(), schema_diff, data_diff) assert result == [_uniqueness_info("TestCar", node_uuids=None)] def test_two_node_scoped_entries_union_their_nodes(self) -> None: - merger = build_constraint_info_merger(schema_branch=_schema_branch()) + merger = build_constraint_info_merger() result = merger.merge( + _schema_branch(), [_uniqueness_info("TestCar", node_uuids=["a", "b"])], [_uniqueness_info("TestCar", node_uuids=["b", "c"])], ) @@ -74,9 +75,10 @@ def test_two_node_scoped_entries_union_their_nodes(self) -> None: assert result == [_uniqueness_info("TestCar", node_uuids=["a", "b", "c"])] def test_distinct_constraints_are_preserved(self) -> None: - merger = build_constraint_info_merger(schema_branch=_schema_branch()) + merger = build_constraint_info_merger() result = merger.merge( + _schema_branch(), [_uniqueness_info("TestCar", node_uuids=["a"])], [_uniqueness_info("TestPerson", node_uuids=["b"])], ) @@ -88,14 +90,14 @@ class TestConstraintInfoMerger: def test_merges_then_deduplicates(self) -> None: # the data diff scopes the generic; the schema diff broadens it to the full population; and # the inherited node check must be dropped as covered by the generic - merger = build_constraint_info_merger(schema_branch=_schema_branch()) + merger = build_constraint_info_merger() data_diff = [ _uniqueness_info("TestCar", node_uuids=["e1"]), _uniqueness_info("TestElectricCar", node_uuids=["e1"]), ] schema_diff = [_uniqueness_info("TestCar", node_uuids=None)] - result = merger.merge(data_diff, schema_diff) + result = merger.merge(_schema_branch(), data_diff, schema_diff) # merge precedence keeps the full-population generic; dedup then removes the covered node assert result == [_uniqueness_info("TestCar", node_uuids=None)] From afe35037ba0eb19ef50d506cb76026ed7f212469 Mon Sep 17 00:00:00 2001 From: Aaron McCarty Date: Thu, 30 Jul 2026 18:55:03 -0500 Subject: [PATCH 65/75] run pnpm codegen:graphql (#10091) --- .../api/graphql/generated/graphql-cache.d.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/frontend/app/src/shared/api/graphql/generated/graphql-cache.d.ts b/frontend/app/src/shared/api/graphql/generated/graphql-cache.d.ts index 4df7a69d78e..768f501af17 100644 --- a/frontend/app/src/shared/api/graphql/generated/graphql-cache.d.ts +++ b/frontend/app/src/shared/api/graphql/generated/graphql-cache.d.ts @@ -1,6 +1,26 @@ /* eslint-disable */ /* prettier-ignore */ import type { TadaDocumentNode, $tada } from 'gql.tada'; +import { + type AnyVariables, + Client, + type CombinedError, + type DocumentInput, + fetchExchange, + formatDocument, + makeOperation, + mapExchange, +} from "@urql/core"; +import { authExchange } from "@urql/exchange-auth"; +import { ERROR_CODES } from "@/shared/api/errors"; +import { handleGraphQLErrors, hasCatalogueCode } from "@/shared/api/graphql/error-handling"; +import type { GraphQLRequestContext, GraphQLResult } from "@/shared/api/graphql/types"; +import { DEFAULT_PRIORITY, PRIORITY_HEADER } from "@/shared/api/priority"; +import { queryClient } from "@/shared/api/rest/client"; +import { CONFIG } from "@/shared/config/config"; +import { getAccessToken } from "@/entities/authentication/api/token-storage"; +import { redirectToLogin } from "@/entities/authentication/domain/use-cases/redirect-to-login"; +import { refreshAccessTokenQueryOptions } from "@/entities/authentication/ui/queries/refresh-access-token.query"; declare module 'gql.tada' { interface setupCache { From 913a48d6e273b99b8d28ea1a77ca5dd3a44f9568 Mon Sep 17 00:00:00 2001 From: Yvonne Date: Fri, 31 Jul 2026 02:08:42 -0400 Subject: [PATCH 66/75] docs: polish 1.8.0 and 1.9.0 release notes (voice, clarity, links) (#9586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: polish 1.9.0 release notes — voice, clarity, learn-more links Apply a voice and clarity pass to the 1.9.0 release notes: - Voice: drop the marketing opener, remove a forbidden word and an idiom, replace product-as-agent value claims, cut vendor-positioning phrasing. - Clarity for an international audience: split over-packed sentences, expand SIEM and hfid on first use, replace residual colloquialisms. - Educational: add a "why" to namespace restrictions; add six "Learn more" links from feature sections to their docs pages. - Fixes: correct the "are can be forwarded" grammar error and resolve the infrahub-sdk version placeholder to 1.20.0. Co-Authored-By: Claude Opus 4.8 * docs: polish 1.8.0 release notes — voice, clarity, learn-more links Apply the same voice and clarity pass to the 1.8.0 notes: - Voice: drop the marketing opener and filler ("now simpler", "continues to evolve", "enhance the day-to-day experience"), fix a first-person "we can now". - Clarity: fix the File Object terminology drift and a comma splice, add four "Learn more" links to the feature docs. - Correctness: fix the duplicate "Second" migration step (-> Third), two grammar errors ("helps speeding up", "command need to run"), and a stray semicolon in the MIME-type list. Co-Authored-By: Claude Opus 4.8 * docs: replace figurative "carry/carries" in 1.9.0 notes "carries the event as JSON" -> "contains"; "Webhooks can now carry ... headers" -> "send". Direct verbs per the voice rule. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Yvonne Co-authored-by: Claude Opus 4.8 Co-authored-by: Damien Garros --- .../release-notes/infrahub/release-1_8_0.mdx | 34 ++++++++++------ .../release-notes/infrahub/release-1_9_0.mdx | 40 ++++++++++++------- 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/docs/docs/release-notes/infrahub/release-1_8_0.mdx b/docs/docs/release-notes/infrahub/release-1_8_0.mdx index 4a97e1d2599..ada9f4807f5 100644 --- a/docs/docs/release-notes/infrahub/release-1_8_0.mdx +++ b/docs/docs/release-notes/infrahub/release-1_8_0.mdx @@ -21,15 +21,15 @@ description: "File objects, automatic branch freeze-on-merge, resource pools in -We're excited to announce the release of Infrahub, v1.8.0! +Infrahub v1.8.0 is now available. -This release introduces a file objects feature, adds stronger branch life-cycle controls with automatic freeze-on-merge, and adds support for resource pools in object templates. The Infrahub Backup tool now fully supports Kubernetes deployments. +This release introduces a file objects feature, adds stronger branch lifecycle controls with automatic freeze-on-merge, and adds support for resource pools in object templates. The Infrahub Backup tool now fully supports Kubernetes deployments. ## Main changes ### File Object: Upload and attach files to Infrahub objects -Infrahub can now store files -- Text files, PDF, images, spreadsheets, KMZ files, and any other format -- directly as objects in the database. With the new `CoreFileObject` generic, you can define custom file types in your schema. Object files behave as all other objects in Infrahub, you can fully customize the schema and relate (or attach) them to other objects. +Infrahub can now store files -- Text files, PDF, images, spreadsheets, KMZ files, and any other format -- directly as objects in the database. With the new `CoreFileObject` generic, you can define custom file types in your schema. File objects behave like all other objects in Infrahub. You can fully customize the schema and relate (or attach) them to other objects. The contents of the file will be rendered in Infrahub's web interface, if the file has one of the following file types: @@ -43,7 +43,7 @@ The contents of the file will be rendered in Infrahub's web interface, if the fi - text/plain - text/markdown - application/xml -- text/csv; +- text/csv - image/png - image/jpeg - image/gif @@ -77,6 +77,8 @@ The maximum file size defaults to 50 MB and is configurable via the `INFRAHUB_ST ![File Object](../../media/release_notes/infrahub_1_8_0/file_object.png) +**Learn more:** [File objects](../../schema/file-object.mdx) + ### Freeze branch after merge When a branch is merged -- either through a direct branch merge or via a Proposed Change -- it now transitions to a frozen state where no further mutations are allowed. This prevents a class of data integrity issues that could occur when a branch was modified or merged a second time after its initial merge. @@ -86,6 +88,8 @@ Both the backend API and the frontend UI enforce this freeze: - The UI disables editing controls and shows a visual indication that the branch is frozen - Creating a new Proposed Change for an already-merged branch is prevented +**Learn more:** [Merging branches](../../branches/merge.mdx) + ### Resource pool references in object templates Object templates can now reference resource pools (IP Address, IP Prefix, and Number pools). Previously, adding a resource pool to a template would allocate a resource to the template itself -- not the intended behavior. In 1.8, pool references on templates are stored as metadata. When an object is created from the template, the resource is allocated from the specified pool at creation time. @@ -99,6 +103,8 @@ The feature introduces a new `relationship_properties` field in the GraphQL sche A database migration is included to convert any existing template-IP relationships that incorrectly have pool sources into the new `_from_resource_pool` relationship format. +**Learn more:** [Allocate resources from pools in templates](../../object-templates/allocate-resources-from-pools.mdx) + ### Consult the diff of a proposed change after branch deletion The data and schema diff of a Proposed Change is now preserved and accessible even after the associated branch has been deleted. This is essential for audit and compliance workflows -- during incident investigation or regulatory review, users need to inspect what changes were made, by whom, and when, regardless of whether the source branch still exists. @@ -107,9 +113,11 @@ The diff data is now tied to the Proposed Change itself rather than solely to th This feature is a prerequisite for a future capability to automatically delete branches after merge, which will help prevent stale branches that can cause Git synchronization issues. -### Read-only repository: update to latest button +**Learn more:** [Proposed changes](../../proposed-changes/overview.mdx) + +### Read-only repository: Update to Latest button -Managing read-only Git repositories is now simpler. A new "Update to Latest" button fetches and imports the latest commit from the tracked branch directly from the UI. Previously, users had to manually copy and paste commit hashes from an external source to update what Infrahub was tracking. The existing reimport action has been renamed to "Reimport Current Commit" to clearly distinguish between re-processing the current commit and pulling the latest. +A new "Update to Latest" button fetches and imports the latest commit from the tracked branch directly from the UI. Previously, users had to manually copy and paste commit hashes from an external source to update what Infrahub was tracking. The existing reimport action has been renamed to "Reimport Current Commit" to clearly distinguish between re-processing the current commit and pulling the latest. Repositories now also support providing a Git Tag as the reference (ref) to track for a repository. @@ -117,7 +125,7 @@ Repositories now also support providing a Git Tag as the reference (ref) to trac The `infrahub-backup` CLI tool now fully supports Kubernetes deployments for both backup and restore operations. The tool automatically detects whether Infrahub is running on Docker Compose or Kubernetes and adjusts its behavior accordingly. -For Kubernetes deployments we can now install `infrahub-backup` using the Infrahub Helm Chart. This is now the recommended installation method for Kubernetes deployments. +For Kubernetes deployments you can now install `infrahub-backup` using the Infrahub Helm Chart. This is now the recommended installation method for Kubernetes deployments. See the [installation instructions](https://docs.infrahub.app/backup/guides/install#enable-via-infrahub-helm-chart-recommended) for more details Key new capabilities: @@ -128,7 +136,7 @@ Key new capabilities: ### Display artifact count in proposed changes -The Proposed Changes detail view now displays item counts on all tabs, giving you immediate visibility into how many changes exist in each category: +The Proposed Changes detail view now displays item counts on all tabs, so you can see how many changes exist in each category: - **Data** -- count of added, updated, and removed nodes - **Files** -- total count of changed files across repositories @@ -139,7 +147,7 @@ The Proposed Changes detail view now displays item counts on all tabs, giving yo ### Branch list page improvements -The branch list page continues to evolve with richer information and better usability: +The branch list page adds: - **Created By** column shows who created each branch (available for branches created after 1.7) - **Proposed Changes** column links directly to associated proposed changes @@ -153,7 +161,7 @@ The branch list page continues to evolve with richer information and better usab ### General UI improvements -Several smaller improvements enhance the day-to-day experience: +Several smaller improvements: - **Schema field shortcuts** -- clicking an attribute or relationship label in the object detail view opens the schema viewer modal, scrolling directly to the relevant field definition - **Field type icons** -- icons next to field names indicate the attribute type (text, number, boolean) and relationship schema @@ -172,7 +180,7 @@ Notable SDK changes in this release: ## Migration of an Infrahub instance -Before you upgrade an instance of Infrahub, we strongly advise you to delete branches that are no longer needed within Infrahub. Deleting old branches helps speeding up the upgrade process and to avoid spending time running migrations for branches that are no longer needed. +Before you upgrade an instance of Infrahub, we strongly advise you to delete branches that are no longer needed within Infrahub. Deleting old branches helps speed up the upgrade process and avoid spending time running migrations for branches that are no longer needed. **Please** make sure to upgrade any existing installations of the infrahub-sdk to v1.19.0. @@ -194,11 +202,11 @@ Below are some example ways to get the latest version of Infrahub in your enviro - `export VERSION="1.8.0"; docker compose pull && docker compose up -d` - For deployments via Kubernetes, utilize the latest version of the Helm chart supplied with this release -**Second**, once you have gotten the desired version of Infrahub in your environment, we need to run any needed migrations. +**Third**, once you have the desired version of Infrahub in your environment, run any needed migrations. Infrahub provides the `infrahub upgrade` command to start these migrations. -> Note: If you are running Infrahub in Docker/K8s, this command need to run from a container where Infrahub is installed. +> Note: If you are running Infrahub in Docker/K8s, this command needs to run from a container where Infrahub is installed. ```shell docker compose exec infrahub-server infrahub upgrade diff --git a/docs/docs/release-notes/infrahub/release-1_9_0.mdx b/docs/docs/release-notes/infrahub/release-1_9_0.mdx index e5a2e29e078..06d050c1732 100644 --- a/docs/docs/release-notes/infrahub/release-1_9_0.mdx +++ b/docs/docs/release-notes/infrahub/release-1_9_0.mdx @@ -22,9 +22,9 @@ description: "Two headline additions: an interactive schema visualizer that turn -We're excited to announce the release of Infrahub, v1.9.0! +Infrahub v1.9.0 is now available. -Two headline additions lead the release: a brand-new **interactive schema visualizer** that turns your schema into a navigable graph, and **syslog log forwarding** for Infrahub Enterprise - giving operators a live view into both the schema and the runtime. Around those, the release is centred on three themes: **performance** - Jinja2 computed attributes now recalculate inline on local changes instead of spawning a background task per node; **artifact composition** - reusable GraphQL fragment files in `.infrahub.yml` and artifact content composition via Jinja2 filters; and **lifecycle & auditability** - automatic branch deletion after merge, login/logout activity events, and custom HTTP headers on webhooks. +Two headline additions lead the release: a brand-new **interactive schema visualizer** that turns your schema into a navigable graph, and **syslog log forwarding** for Infrahub Enterprise - so operators can see both the schema and the runtime. Alongside these, the release is centred on three themes: **performance** - Jinja2 computed attributes now recalculate inline on local changes instead of spawning a background task per node; **artifact composition** - reusable GraphQL fragment files in `.infrahub.yml` and artifact content composition via Jinja2 filters; and **lifecycle & auditability** - automatic branch deletion after merge, login/logout activity events, and custom HTTP headers on webhooks. ⚠️ After upgrading to this release, every Infrahub user account that originated from an OAuth2 or OIDC identity provider must log in again. The login flow captures additional information that Infrahub now stores against these accounts. @@ -36,25 +36,27 @@ A future release will require this information to already be present. Any accoun A new **schema graph visualizer** turns your schema into an interactive, navigable graph. Instead of opening YAML files or paging through the schema viewer one schema node at a time, you can see the whole model - nodes, generics, profiles, and templates - laid out together with their relationships drawn as edges between them. -The visualizer is a canvas you can drag, zoom, and pan, with an automatic layout. Each schema kind is colour-coded so you can tell nodes, generics, profiles, and templates apart at a glance. Relationship edges with `many` cardinality are animated, and self-referencing relationships are highlighted. +The visualizer is a canvas you can drag, zoom, and pan, with an automatic layout. Each schema kind is colour-coded so you can tell nodes, generics, profiles, and templates apart immediately. Relationship edges with `many` cardinality are animated, and self-referencing relationships are highlighted. You'll find it as a new **Graph** toggle on the Schema page, switching between the existing schema viewer and the graph view. Clicking the `view in graph` button in a schema node's details opens the graph with that node pre-highlighted. The visualizer includes: -- A **filter panel** to toggle whole namespaces or individual schema types in and out of the view, so you can isolate just the part of the model you care about. +- A **filter panel** to toggle whole namespaces or individual schema types in and out of the view, so you can isolate the part of the model you care about. - A **node details panel** that opens on click to show the selected schema's attributes and relationships, so you don't need to switch tabs to read the definition. - Context menus on nodes and edges for quick navigation. - **Zoom, pan, and fit-to-view** controls plus **PNG export** of the current view - useful for docs and architecture reviews. -- State persistence, so the filters and layout you set up stick around between sessions. +- State persistence, so the filters and layout you set up persist between sessions. ![Schema visualizer](../../media/release_notes/infrahub_1_9_0/schema_visualizer.png) ### Syslog log forwarding *(Enterprise)* -Infrahub Enterprise now supports native Syslog forwarding to external SIEM systems such as Splunk, Datadog, and ELK. All `infrahub.*` activity events - including the new login/logout events - are can be forwarded continuously. Permission-denied errors on rejected GraphQL and REST requests are forwarded as `infrahub.permission.denied` events, and each destination can optionally include Infrahub's own application logs, filtered by a minimum severity. The MSG field of each syslog entry carries the JSON representation of the event. +Infrahub Enterprise now supports native syslog forwarding to external SIEM (Security Information and Event Management) systems such as Splunk, Datadog, and ELK. All `infrahub.*` activity events can be forwarded continuously, including the new login/logout events. Permission-denied errors on rejected GraphQL and REST requests are forwarded as `infrahub.permission.denied` events. Each destination can also include Infrahub's own application logs, filtered by a minimum severity. The MSG field of each syslog entry contains the event as JSON. -Configuration is via the Infrahub configuration file or environment variables and supports multiple destinations, TCP or UDP transport, optional TLS, and both RFC 5424 and RFC 3164 formats - designed to meet enterprise security and compliance requirements (SOC2, ISO 27001). +Configuration is via the Infrahub configuration file or environment variables and supports multiple destinations, TCP or UDP transport, optional TLS, and both RFC 5424 and RFC 3164 formats, covering common SOC2 and ISO 27001 logging requirements. + +**Learn more:** [Log forwarding](../../deploy-manage/run-observe/log-forwarding/overview.mdx) ### Modular GraphQL queries with reusable fragments @@ -95,17 +97,21 @@ query DeviceDetails($name: String!) { Transitive dependencies are resolved automatically - a fragment that spreads another fragment brings both along. Unresolvable references fail at sync time with an actionable error identifying the query and fragment. The same rendering applies when executing queries locally via `infrahubctl`, so IDE workflows keep working. +**Learn more:** [GraphQL fragments](../../development-resources/graphql-fragments.mdx) + ### Artifact content composition Jinja2 Transformations gain a new set of filters for composing an artifact from the content of other artifacts: - `artifact_content` takes a `storage_id` and returns the rendered content of another artifact as a string. - `file_object_content` does the same for a `CoreFileObject`. -- `file_object_content_by_hfid` takes the `hfid` of a `CoreFileObject` and returns the content as a string +- `file_object_content_by_hfid` takes the human-friendly ID (`hfid`) of a `CoreFileObject` and returns the content as a string - `file_object_content_by_id` takes the `id` of a `CoreFileObject` and returns the content as a string - `from_json` and `from_yaml` parse the inlined content so the composing template can traverse it as structured data. -This lets a template inline and parse sections from other artifacts without duplicating the template logic that produced them. Python Transformations can achieve the same by calling `object_store.get()` via the SDK. +A template can inline and parse sections from other artifacts without duplicating the template logic that produced them. Python Transformations can achieve the same by calling `object_store.get()` via the SDK. + +**Learn more:** [Artifact content composition](../../artifacts/content-composition.mdx) ### Display options for attributes and relationships @@ -125,6 +131,8 @@ attributes: The IPAM detail pages have been unified with the standard object-details card in the same pass, picking up field metadata, the **Extra** toggle, metadata editing, and profiles & groups tabs. +**Learn more:** [Field visibility](../../schema/field-visibility.mdx) + ### Delete branch after merge Merged branches can now be cleaned up automatically. Two opt-in configuration flags control the behaviour: @@ -138,17 +146,19 @@ The `BranchDelete` GraphQL mutation accepts a `delete_from_git` parameter to ove ### Custom HTTP headers on webhooks -Webhooks can now carry arbitrary custom HTTP headers - the most common use case being authentication to target systems that require `Authorization: Bearer ` or similar. +Webhooks can now send arbitrary custom HTTP headers - the most common use case being authentication to target systems that require `Authorization: Bearer ` or similar. A single header can be attached to multiple webhooks, so rotating a credential in one place propagates to all of them on the next event. Two new node kinds back this, both implementing a shared `CoreKeyValue` generic: - **`CoreStaticKeyValue`** - the value is stored as-is in Infrahub. Appropriate for system identifiers, tenant IDs, or tokens that don't need external secret management. -- **`CoreEnvKeyValue`** - only the environment-variable **name** is stored; the actual value is resolved from the worker process environment at send time. The stored configuration never contains the secret, which lets secret managers (Kubernetes secrets, Vault, Delinea) inject credentials the usual way. Missing variables are logged with a warning and the header is skipped; the remainder of the request is sent intact. +- **`CoreEnvKeyValue`** - only the environment-variable **name** is stored; the actual value is resolved from the worker process environment at send time. The stored configuration never contains the secret, so secret managers (Kubernetes secrets, Vault, Delinea) can inject credentials through their normal mechanism. Missing variables are logged with a warning and the header is skipped; the remainder of the request is sent intact. If a custom header collides with a system-reserved header name (for example: `Content-Type`), the user's value wins. Although shipped to solve the webhook-authentication use case, `CoreKeyValue` and its two implementations are not webhook-specific - they can be reused anywhere in other contexts as well. +**Learn more:** [Webhooks](../../webhooks/overview.mdx) + ### IPAM: closest parent prefix lookup in search The search-anywhere dialog now understands IP addresses and CIDR prefixes. Typing `10.1.2.45` returns: @@ -189,7 +199,7 @@ The Accounts, Groups, Roles, and Global Permissions tables in Role Manager now s ### Namespace restrictions on generics -Generic schemas now accept a namespace restriction parameter that limits which namespaces can inherit from them. This is enforced at schema-load time. **This affects existing extensions of `CoreGenericRepository` and `CoreWebhook`** - see the Breaking changes section below. +Generic schemas now accept a namespace restriction parameter that limits which namespaces can inherit from them. Use it to prevent other namespaces from extending a core generic unintentionally. This is enforced at schema-load time. **This affects existing extensions of `CoreGenericRepository` and `CoreWebhook`** - see the Breaking changes section below. ### Computed attributes: local execution @@ -203,7 +213,9 @@ Concretely: - Each local-change mutation emits **one** consolidated event/webhook containing both the original change and the updated computed attribute, instead of two separate events. - **Remote** changes (peer-node updates that affect computed attributes on other nodes) continue to use the existing background-task path - no behavioural change there. Python Transform-based computed attributes are also unchanged. -This eliminates the single most common source of trivial background-task load in large imports and bulk edits. +This eliminates the single most common source of background-task load from trivial local changes in large imports and bulk edits. + +**Learn more:** [Computed attributes](../../computed-attributes/overview.mdx) ### Schema selector: sticky search and expand/collapse @@ -269,7 +281,7 @@ Before you upgrade an instance of Infrahub, we strongly advise you to delete bra **Please** read the Breaking changes section above before starting the upgrade. In particular, confirm you have no schema extensions inheriting from `CoreGenericRepository` or `CoreWebhook`, and that no client code still references the removed GraphQL queries or `_updated_at` field. -**Please** make sure to upgrade any existing installations of the infrahub-sdk to v``. +**Please** make sure to upgrade any existing installations of the infrahub-sdk to v`1.20.0`. **Please** make sure to backup your instance of Infrahub and make sure you are familiar with and have tested the restore procedure. For more information visit https://docs.infrahub.app/backup From 90bb0bde3a3a6a81df919ac3afde6749caf55d11 Mon Sep 17 00:00:00 2001 From: Saltaferis Dimitrios Date: Fri, 31 Jul 2026 12:10:06 +0300 Subject: [PATCH 67/75] refactor(telemetry): type the kind-count query to concrete schemas The count-by-kind query matches on the vertex kind property, which only ever holds a node's concrete kind; a generic passed as a plain string would silently count zero. Accept list[NodeSchema] instead of list[str] so handing it a GenericSchema is a type error caught by mypy/ty, and document the property-vs-labels constraint in the docstring. Co-Authored-By: Claude Fable 5 --- backend/infrahub/telemetry/database.py | 8 ++++---- backend/infrahub/telemetry/queries.py | 13 ++++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/backend/infrahub/telemetry/database.py b/backend/infrahub/telemetry/database.py index 404ba579278..e214d92f9e8 100644 --- a/backend/infrahub/telemetry/database.py +++ b/backend/infrahub/telemetry/database.py @@ -62,14 +62,14 @@ async def count_user_nodes(db: InfrahubDatabase) -> int: default_branch = registry.get_branch_from_registry() schema_branch = db.schema.get_schema_branch(name=default_branch.name) user_namespaces = [namespace.name for namespace in schema_branch.get_namespaces() if namespace.user_editable] - kinds = [ - node_schema.kind + schemas = [ + node_schema for node_schema in schema_branch.get_schemas_for_namespaces(namespaces=user_namespaces) if isinstance(node_schema, NodeSchema) and InfrahubKind.GENERICGROUP not in node_schema.inherit_from ] - if not kinds: + if not schemas: return 0 - query = await CountNodesByKindsQuery.init(db=db, branch=default_branch, kinds=kinds) + query = await CountNodesByKindsQuery.init(db=db, branch=default_branch, schemas=schemas) await query.execute(db=db) return sum(item.count for item in query.get_data()) diff --git a/backend/infrahub/telemetry/queries.py b/backend/infrahub/telemetry/queries.py index ff49703a64d..6d7ba79c42c 100644 --- a/backend/infrahub/telemetry/queries.py +++ b/backend/infrahub/telemetry/queries.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from collections.abc import Generator + from infrahub.core.schema import NodeSchema from infrahub.database import InfrahubDatabase @@ -39,18 +40,24 @@ class NodeKindCount: class CountNodesByKindsQuery(Query): - """Count active nodes of the given kinds on the query's branch at the query's time. + """Count active nodes of the given concrete kinds on the query's branch at the query's time. One pass over the graph replaces a per-kind count query fan-out; kinds with no active node return no row. + + Concrete node schemas only: the match is on the vertex ``kind`` property, which + always holds the node's concrete kind. A generic kind never appears there (it is + carried only in the vertex labels), so matching a generic would silently count + zero. Supporting generics would require matching on labels instead, and a sum over + label matches double-counts nodes inheriting several of the requested kinds. """ name = "count-nodes-by-kinds" type = QueryType.READ insert_return = False - def __init__(self, kinds: list[str], **kwargs: Any) -> None: - self.kinds = kinds + def __init__(self, schemas: list[NodeSchema], **kwargs: Any) -> None: + self.kinds = [schema.kind for schema in schemas] super().__init__(**kwargs) async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: # noqa: ARG002 From 42101dec9a729377d683c5b31ea9d19b5469ed15 Mon Sep 17 00:00:00 2001 From: Aaron McCarty Date: Fri, 31 Jul 2026 09:15:45 -0500 Subject: [PATCH 68/75] Add maskless `IPAddress` attribute kind (#10090) * refactor(core): make _normalize_value a classmethod Allow a value to be checked for canonicality without building an attribute instance. No override used instance state, so behavior is unchanged. Co-Authored-By: Claude Opus 5 (1M context) * feat(core): add the IPAddress attribute kind Store a bare IP address, rejecting any value that carries a prefix length or netmask, and normalize IPv6 to its compressed lowercase form. Reuse the AttributeIPHost value vertex and its binary index by returning AttributeDBNodeType.IPHOST, so no graph schema change, index or migration is needed. prefixlen is written as the family maximum because a bare address is a single host and neo4j refuses to MERGE on a null property; it is deliberately absent from the GraphQL type and from get_allowed_property_in_path. Values sort lexically rather than numerically, since the shared binary index is only reachable through IPAM's own queries. Co-Authored-By: Claude Opus 5 (1M context) * feat(schema): reject non-canonical values when changing an attribute kind A kind change does not rewrite stored values, so switching an attribute to IPHost left a bare 10.0.0.1 in the database where the canonical form is 10.0.0.1/32, making value filters and uniqueness comparisons match a stale string. Flag any stored value that is not already canonical for the new kind. The check needs no list of the kinds it applies to: kinds that do not normalize inherit an identity _normalize_value, so it is a no-op for them and covers a future normalizing kind for free. In practice it applies to IPHost, IPNetwork, IPAddress and MacAddress. Co-Authored-By: Claude Opus 5 (1M context) * chore(generated): regenerate schema, docs and frontend types for IPAddress Output of backend.generate, schema.generate-graphqlschema, schema.generate-jsonschema, docs.generate and the frontend codegen. The graphql-cache.d.ts churn is gql.tada rehashing every cached document because the schema changed; none of it is specific to the new kind. Co-Authored-By: Claude Opus 5 (1M context) * feat(frontend): support the IPAddress attribute kind Register the kind so it renders as a plain text input in object forms, shows as a column in list views, and offers a text filter. The four switches it is added to fall through to warnUnexpectedType and render nothing for an unknown kind, so each case is what keeps the field from silently disappearing. Validate the value in the form as well, since a prefixed address is a mistake worth catching before the round trip: a value containing a slash is reported as needing a bare address, and anything else malformed as an invalid IPv4 or IPv6 address. Leading zeros are rejected to match the backend. Co-Authored-By: Claude Opus 5 (1M context) * docs: document the IPAddress attribute kind Add the kind to the hand-written kind list and the UI-behaviour table, and note on the IPHost entry that it stores a prefix so the two read as distinct choices. Record in the knowledge doc that _normalize_value is a classmethod so a value can be checked without an attribute instance, and that a kind change is only allowed when the stored values are already canonical for the new kind. Co-Authored-By: Claude Opus 5 (1M context) * chore(sdk): bump python_sdk for the IPAddress attribute kind Advances python_sdk to the latest infrahub-develop and adds the generated IPAddress attribute-kind models, which unblocks the SDK node tests for a bare address. The pointer spans 14 upstream commits as well as the IPAddress change, mostly the infrahubctl schema-format work, so an unrelated SDK regression bisected to this commit most likely comes from those. Co-Authored-By: Claude Opus 5 (1M context) * fix(frontend): count IPv6 groups correctly in the IPAddress validator A trailing dotted quad carries 32 bits, so it stands for the final two groups rather than one. Counting it as one rejected valid addresses such as 1:2:3:4:5:6:1.2.3.4. Allowing eight explicit groups alongside a "::" run also accepted invalid ones such as ::1:2:3:4:5:6:7:8, since the run has to stand for at least one group of zeros. The accept and reject cases are checked against ipaddress.ip_address so the form validation and the backend agree on the same inputs. Also refresh the betterer results: adding IPAddress to ATTRIBUTE_KIND changed the content hashes of the two switch files and the text of several recorded errors that embed the AttributeKind union, which the CI-mode check treats as a change even though the error count is unchanged. Co-Authored-By: Claude Opus 5 (1M context) * add "netmask" to spelling exceptions * bump python sdk to latest infrahub-develop after SDK PR merged --------- Co-authored-by: Claude Opus 5 (1M context) --- .vale/styles/spelling-exceptions.txt | 1 + backend/infrahub/core/attribute.py | 103 ++++++++++++- backend/infrahub/core/utils.py | 7 +- .../core/validators/attribute/kind.py | 18 +++ backend/infrahub/graphql/types/__init__.py | 2 + backend/infrahub/graphql/types/attribute.py | 10 ++ backend/infrahub/types.py | 12 ++ backend/templates/generate_protocols.j2 | 2 +- backend/templates/generate_protocols_sdk.j2 | 2 +- backend/tests/component/conftest.py | 1 + .../test_attribute_kind_update.py | 138 ++++++++++++++++++ .../tests/component/core/test_manager_node.py | 26 +++- .../tests/component/core/test_node_query.py | 22 +++ .../component/graphql/test_graphql_query.py | 47 ++++++ backend/tests/unit/core/test_attribute.py | 129 ++++++++++++++++ backend/tests/unit/test_types.py | 9 +- ...te-kind-change-canonical-values.changed.md | 1 + changelog/+ipaddress-attribute-kind.added.md | 1 + .../backend/display-labels-and-hfid.md | 5 + docs/docs/reference/schema/attribute.mdx | 2 +- docs/docs/schema/nodes-and-attributes.mdx | 4 +- frontend/app/.betterer.results | 20 +-- .../nodes/getObjectItemDisplayValue.tsx | 1 + ...et-attributes-visible-in-list-view.test.ts | 2 + .../ui/filters/dynamic-filter-input.tsx | 1 + .../cells/table-attribute-cell.tsx | 1 + .../schema/domain/model/attribute-kind.ts | 2 + .../validate-ip-address-attribute.test.ts | 68 +++++++++ .../validate-ip-address-attribute.ts | 62 ++++++++ .../entities/schema/ui/field-schema-icon.tsx | 1 + .../api/graphql/generated/graphql-cache.d.ts | 132 ++++++++--------- .../api/graphql/generated/graphql-env.d.ts | 3 +- .../src/shared/api/rest/types.generated.ts | 4 +- .../shared/components/form/dynamic-form.tsx | 1 + .../form/utils/getFormFieldFromAttribute.ts | 10 ++ python_sdk | 2 +- schema/openapi.json | 9 ++ schema/schema.graphql | 18 +++ tasks/backend.py | 1 + tasks/shared.py | 1 + 40 files changed, 788 insertions(+), 93 deletions(-) create mode 100644 backend/tests/unit/core/test_attribute.py create mode 100644 changelog/+attribute-kind-change-canonical-values.changed.md create mode 100644 changelog/+ipaddress-attribute-kind.added.md create mode 100644 frontend/app/src/entities/schema/domain/rules/validation/validate-ip-address-attribute.test.ts create mode 100644 frontend/app/src/entities/schema/domain/rules/validation/validate-ip-address-attribute.ts diff --git a/.vale/styles/spelling-exceptions.txt b/.vale/styles/spelling-exceptions.txt index 386856f9532..906f58a0273 100644 --- a/.vale/styles/spelling-exceptions.txt +++ b/.vale/styles/spelling-exceptions.txt @@ -161,6 +161,7 @@ namespaces nats Nautobot Neo4j +netmask NGINX Netbox Netutils diff --git a/backend/infrahub/core/attribute.py b/backend/infrahub/core/attribute.py index fbce82e7e7e..ae2fbba17cd 100644 --- a/backend/infrahub/core/attribute.py +++ b/backend/infrahub/core/attribute.py @@ -373,8 +373,13 @@ def deserialize_value(self, data: AttributeFromDB) -> Any: """Deserialize the value coming from the database.""" return data.value - def _normalize_value(self, value: Any) -> Any: - """Return the canonical form of a value.""" + @classmethod + def _normalize_value(cls, value: Any) -> Any: + """Return the canonical form of a value. + + Exposed on the class so that a value can be checked for canonicality without building an + attribute instance. + """ return value async def save( @@ -1007,7 +1012,8 @@ def validate_format(cls, value: Any, name: str, schema: AttributeSchema) -> None except ValueError as exc: raise ValidationError({name: f"{value} is not a valid {schema.kind}"}) from exc - def _normalize_value(self, value: Any) -> str: + @classmethod + def _normalize_value(cls, value: Any) -> str: return ipaddress.ip_network(value).with_prefixlen def get_db_node_type(self) -> AttributeDBNodeType: @@ -1147,7 +1153,8 @@ def validate_format(cls, value: Any, name: str, schema: AttributeSchema) -> None except ValueError as exc: raise ValidationError({name: f"{value} is not a valid {schema.kind}"}) from exc - def _normalize_value(self, value: Any) -> str: + @classmethod + def _normalize_value(cls, value: Any) -> str: return ipaddress.ip_interface(value).with_prefixlen def get_db_node_type(self) -> AttributeDBNodeType: @@ -1170,6 +1177,91 @@ class IPHostOptional(IPHost): value: str | None +class IPAddress(BaseAttribute): + type = str + value: str + + @staticmethod + def get_allowed_property_in_path() -> list[str]: + return ["binary_address", "value", "version"] + + @property + def obj(self) -> ipaddress.IPv4Address | ipaddress.IPv6Address: + """Return the bare ip address without a prefix or subnet mask. + + Raises: + ValueError: When the IP address value has not been defined. + + """ + if not self.value: + raise ValueError("value for IPAddress must be defined") + return ipaddress.ip_address(str(self.value)) + + @property + def version(self) -> int | None: + """Return the IP version of the ip address.""" + if not self.value: + return None + return self.obj.version + + @property + def ip_integer(self) -> int: + """Return the ip address as an integer.""" + return int(self.obj) + + @property + def ip_binary(self) -> str: + """Return the ip address in binary format.""" + return convert_ip_to_binary_str(obj=self.obj) + + @classmethod + def validate_format(cls, value: Any, name: str, schema: AttributeSchema) -> None: + """Validate the format of the attribute. + + A bare address is required, so any prefix length or netmask suffix is rejected. + + Args: + value (Any): value to validate + name (str): name of the attribute to include in a potential error message + schema (AttributeSchema): schema for this attribute + + Raises: + ValidationError: Format of the attribute value is not valid + + """ + super().validate_format(value=value, name=name, schema=schema) + + try: + ipaddress.ip_address(value) + except ValueError as exc: + raise ValidationError({name: f"{value} is not a valid {schema.kind}"}) from exc + + @classmethod + def _normalize_value(cls, value: Any) -> str: + return str(ipaddress.ip_address(value)) + + def get_db_node_type(self) -> AttributeDBNodeType: + if self.value is not None: + return AttributeDBNodeType.IPHOST + return super().get_db_node_type() + + def to_db(self) -> dict[str, Any]: + data = super().to_db() + + if self.value is not None: + data["version"] = self.version + data["binary_address"] = self.ip_binary + # The shared AttributeIPHost vertex requires prefixlen, and neo4j refuses to MERGE on a + # null property. A bare address is a single host, so its prefix length is the maximum. + data["prefixlen"] = self.obj.max_prefixlen + + return data + + +class IPAddressOptional(IPAddress): + value: str | None + + class MacAddress(BaseAttribute): type = str value: str @@ -1273,7 +1365,8 @@ def validate_format(cls, value: Any, name: str, schema: AttributeSchema) -> None if not netaddr.valid_mac(addr=str(value)): raise ValidationError({name: f"{value} is not a valid {schema.kind}"}) - def _normalize_value(self, value: Any) -> str: + @classmethod + def _normalize_value(cls, value: Any) -> str: return netaddr.EUI(addr=value).format(dialect=netaddr.mac_unix_expanded).upper() def serialize_value(self) -> str: diff --git a/backend/infrahub/core/utils.py b/backend/infrahub/core/utils.py index 23852f2da6f..63c71747b3d 100644 --- a/backend/infrahub/core/utils.py +++ b/backend/infrahub/core/utils.py @@ -160,7 +160,12 @@ def parse_node_kind(kind: str) -> NodeKind: def convert_ip_to_binary_str( - obj: ipaddress.IPv6Network | ipaddress.IPv4Network | ipaddress.IPv4Interface | ipaddress.IPv6Interface, + obj: ipaddress.IPv6Network + | ipaddress.IPv4Network + | ipaddress.IPv4Interface + | ipaddress.IPv6Interface + | ipaddress.IPv4Address + | ipaddress.IPv6Address, ) -> str: if isinstance(obj, ipaddress.IPv6Network | ipaddress.IPv4Network): prefix_bin = f"{int(obj.network_address):b}" diff --git a/backend/infrahub/core/validators/attribute/kind.py b/backend/infrahub/core/validators/attribute/kind.py index 2bf2f836f4b..c9d671616fa 100644 --- a/backend/infrahub/core/validators/attribute/kind.py +++ b/backend/infrahub/core/validators/attribute/kind.py @@ -12,6 +12,7 @@ from ..shared import AttributeSchemaValidatorQuery if TYPE_CHECKING: + from infrahub.core.attribute import BaseAttribute from infrahub.core.branch import Branch from infrahub.database import InfrahubDatabase @@ -75,6 +76,7 @@ async def get_paths(self) -> GroupedDataPaths: infrahub_attribute_class.validate_content( value=attr_value, name=self.attribute_schema.name, schema=self.attribute_schema ) + self._validate_value_is_canonical(value=attr_value, attribute_class=infrahub_attribute_class) except ValidationError: grouped_data_paths.add_data_path( DataPath( @@ -88,6 +90,22 @@ async def get_paths(self) -> GroupedDataPaths: ) return grouped_data_paths + def _validate_value_is_canonical(self, value: Any, attribute_class: type[BaseAttribute]) -> None: + """Reject a value that parses under the new kind but is not stored in that kind's canonical form. + + A kind change does not rewrite stored values, so a value such as ``10.0.0.1`` would survive a + change to ``IPHost`` while the canonical form is ``10.0.0.1/32``, leaving value filters and + uniqueness comparisons matching against a stale string. + + Raises: + ValidationError: The value is not canonical for the new kind + + """ + if attribute_class._normalize_value(value) != value: + raise ValidationError( + {self.attribute_schema.name: f"{value} is not stored as a valid {self.attribute_schema.kind}"} + ) + class AttributeKindChecker(ConstraintCheckerInterface): query_classes = [AttributeKindUpdateValidatorQuery] diff --git a/backend/infrahub/graphql/types/__init__.py b/backend/infrahub/graphql/types/__init__.py index e87286c853a..2015f7e07d9 100644 --- a/backend/infrahub/graphql/types/__init__.py +++ b/backend/infrahub/graphql/types/__init__.py @@ -9,6 +9,7 @@ DropdownFields, DropdownType, IntAttributeType, + IPAddressType, IPHostType, IPNetworkType, JSONAttributeType, @@ -40,6 +41,7 @@ "CheckboxAttributeType", "DropdownFields", "DropdownType", + "IPAddressType", "IPHostType", "IPNetworkType", "InfrahubBranch", diff --git a/backend/infrahub/graphql/types/attribute.py b/backend/infrahub/graphql/types/attribute.py index 0a8a92b46d0..cd1439b3145 100644 --- a/backend/infrahub/graphql/types/attribute.py +++ b/backend/infrahub/graphql/types/attribute.py @@ -145,6 +145,16 @@ class Meta: interfaces = {AttributeInterface} +class IPAddressType(BaseAttribute): + value = Field(String) + version = Field(Int) + + class Meta: + description = "Attribute of type IPAddress" + name = "IPAddress" + interfaces = {AttributeInterface} + + class MacAddressType(BaseAttribute): value = Field(String) oui = Field(String) diff --git a/backend/infrahub/types.py b/backend/infrahub/types.py index afbc9faea61..47588018b16 100644 --- a/backend/infrahub/types.py +++ b/backend/infrahub/types.py @@ -269,6 +269,16 @@ class IPNetwork(InfrahubDataType): infrahub = "IPNetwork" +class IPAddress(InfrahubDataType): + label: str = "IPAddress" + graphql = graphene.String + graphql_query = "IPAddressType" + graphql_create = "TextAttributeCreate" + graphql_update = "TextAttributeUpdate" + graphql_filter = graphene.String + infrahub = "IPAddress" + + class Boolean(InfrahubDataType): label: str = "Boolean" graphql = graphene.Boolean @@ -337,6 +347,7 @@ class Any(InfrahubDataType): "Bandwidth": Bandwidth, "IPHost": IPHost, "IPNetwork": IPNetwork, + "IPAddress": IPAddress, "Boolean": Boolean, "Checkbox": Checkbox, "List": List, @@ -362,6 +373,7 @@ class Any(InfrahubDataType): "Bandwidth": float, # Bandwidth in some units, represented as a float "IPHost": IPvAnyAddress, # type: ignore[dict-item] "IPNetwork": str, + "IPAddress": IPvAnyAddress, # type: ignore[dict-item] "Boolean": bool, "Checkbox": bool, # Checkboxes represent boolean values "List": list[Any], # Lists can contain any type of items diff --git a/backend/templates/generate_protocols.j2 b/backend/templates/generate_protocols.j2 index 27c53ce59f5..95e16f7fa7e 100644 --- a/backend/templates/generate_protocols.j2 +++ b/backend/templates/generate_protocols.j2 @@ -8,7 +8,7 @@ from infrahub.core.protocols_base import CoreNode if TYPE_CHECKING: from enum import Enum - from infrahub.core.attribute import Boolean, DateTime, Dropdown, HashedPassword, Integer, IPHost, IPNetwork, JSONAttribute, ListAttribute, String, URL, BooleanOptional, DateTimeOptional, DropdownOptional, HashedPasswordOptional, IntegerOptional, IPHostOptional, IPNetworkOptional, JSONAttributeOptional, ListAttributeOptional, StringOptional, URLOptional + from infrahub.core.attribute import Boolean, DateTime, Dropdown, HashedPassword, Integer, IPAddress, IPHost, IPNetwork, JSONAttribute, ListAttribute, String, URL, BooleanOptional, DateTimeOptional, DropdownOptional, HashedPasswordOptional, IntegerOptional, IPAddressOptional, IPHostOptional, IPNetworkOptional, JSONAttributeOptional, ListAttributeOptional, StringOptional, URLOptional from infrahub.core.relationship import RelationshipManager diff --git a/backend/templates/generate_protocols_sdk.j2 b/backend/templates/generate_protocols_sdk.j2 index c482e913906..e6d41322885 100644 --- a/backend/templates/generate_protocols_sdk.j2 +++ b/backend/templates/generate_protocols_sdk.j2 @@ -9,7 +9,7 @@ from .protocols_base import CoreNode, CoreNodeSync if TYPE_CHECKING: from datetime import datetime from infrahub_sdk.node import RelatedNodeSync, RelationshipManagerSync, RelatedNode, RelationshipManager - from .protocols_base import String, StringOptional, Integer, IntegerOptional, Boolean, BooleanOptional, URL, URLOptional, Dropdown, DropdownOptional, Enum, EnumOptional, DateTime, DateTimeOptional, IPHost, IPHostOptional, IPNetwork, IPNetworkOptional, HashedPassword, HashedPasswordOptional, JSONAttribute, JSONAttributeOptional, ListAttribute, ListAttributeOptional + from .protocols_base import String, StringOptional, Integer, IntegerOptional, Boolean, BooleanOptional, URL, URLOptional, Dropdown, DropdownOptional, Enum, EnumOptional, DateTime, DateTimeOptional, IPAddress, IPAddressOptional, IPHost, IPHostOptional, IPNetwork, IPNetworkOptional, HashedPassword, HashedPasswordOptional, JSONAttribute, JSONAttributeOptional, ListAttribute, ListAttributeOptional # --------------------------------------------- diff --git a/backend/tests/component/conftest.py b/backend/tests/component/conftest.py index 85df33f8a71..d8e71e942e2 100644 --- a/backend/tests/component/conftest.py +++ b/backend/tests/component/conftest.py @@ -1658,6 +1658,7 @@ async def all_attribute_types_schema( {"name": "myjson", "kind": "JSON", "optional": True}, {"name": "ipaddress", "kind": "IPHost", "optional": True}, {"name": "prefix", "kind": "IPNetwork", "optional": True}, + {"name": "bare_address", "kind": "IPAddress", "optional": True}, ], } diff --git a/backend/tests/component/core/constraint_validators/test_attribute_kind_update.py b/backend/tests/component/core/constraint_validators/test_attribute_kind_update.py index aabb4a3e7d3..e00ed6a058f 100644 --- a/backend/tests/component/core/constraint_validators/test_attribute_kind_update.py +++ b/backend/tests/component/core/constraint_validators/test_attribute_kind_update.py @@ -174,6 +174,144 @@ async def test_query_update_on_branch_with_too_large_value( ) +async def _get_kind_change_data_paths( + db: InfrahubDatabase, branch: Branch, node_schema: NodeSchema, field_name: str, new_kind: str +) -> set[DataPath]: + attr = node_schema.get_attribute(name=field_name) + attr.kind = new_kind + registry.schema.set(name=node_schema.kind, schema=node_schema, branch=branch.name) + + schema_path = SchemaPath(path_type=SchemaPathType.ATTRIBUTE, schema_kind=node_schema.kind, field_name=field_name) + query = await AttributeKindUpdateValidatorQuery.init( + db=db, branch=branch, node_schema=node_schema, schema_path=schema_path + ) + await query.execute(db=db) + + return set((await query.get_paths()).get_all_data_paths()) + + +async def test_query_iphost_to_ipaddress_is_blocked( + db: InfrahubDatabase, default_branch: Branch, all_attribute_types_schema: NodeSchema +) -> None: + """An IPHost value carries a prefix, which is not a valid IPAddress, so the change is refused.""" + node = await Node.init(db=db, schema=all_attribute_types_schema.kind, branch=default_branch) + await node.new(db=db, name="host", ipaddress="10.0.0.1/32") + await node.save(db=db) + + all_data_paths = await _get_kind_change_data_paths( + db=db, + branch=default_branch, + node_schema=all_attribute_types_schema, + field_name="ipaddress", + new_kind="IPAddress", + ) + + assert all_data_paths == { + DataPath( + branch=default_branch.name, + path_type=PathType.ATTRIBUTE, + node_id=node.id, + kind=all_attribute_types_schema.kind, + field_name="ipaddress", + value="10.0.0.1/32", + ) + } + + +async def test_query_ipaddress_to_iphost_is_blocked( + db: InfrahubDatabase, default_branch: Branch, all_attribute_types_schema: NodeSchema +) -> None: + """A bare address parses as an IPHost but is not canonical for it, and no migration rewrites it.""" + node = await Node.init(db=db, schema=all_attribute_types_schema.kind, branch=default_branch) + await node.new(db=db, name="host", bare_address="10.0.0.1") + await node.save(db=db) + + all_data_paths = await _get_kind_change_data_paths( + db=db, + branch=default_branch, + node_schema=all_attribute_types_schema, + field_name="bare_address", + new_kind="IPHost", + ) + + assert all_data_paths == { + DataPath( + branch=default_branch.name, + path_type=PathType.ATTRIBUTE, + node_id=node.id, + kind=all_attribute_types_schema.kind, + field_name="bare_address", + value="10.0.0.1", + ) + } + + +async def test_query_iphost_to_ipnetwork_with_canonical_values_is_allowed( + db: InfrahubDatabase, default_branch: Branch, all_attribute_types_schema: NodeSchema +) -> None: + """A value already canonical for the new IP kind is left alone, the check is not block-everything.""" + node = await Node.init(db=db, schema=all_attribute_types_schema.kind, branch=default_branch) + await node.new(db=db, name="net", ipaddress="10.0.0.0/24") + await node.save(db=db) + + all_data_paths = await _get_kind_change_data_paths( + db=db, + branch=default_branch, + node_schema=all_attribute_types_schema, + field_name="ipaddress", + new_kind="IPNetwork", + ) + + assert all_data_paths == set() + + +async def test_query_text_to_macaddress_with_non_canonical_value_is_blocked( + db: InfrahubDatabase, default_branch: Branch, car_accord_main: Node +) -> None: + """MacAddress normalizes too, so a dash-delimited value is refused rather than left un-rewritten.""" + car = await NodeManager.get_one(db=db, branch=default_branch, id=car_accord_main.id) + car.get_attribute("name").value = "aa-bb-cc-dd-ee-ff" + await car.save(db=db) + + all_data_paths = await _get_kind_change_data_paths( + db=db, + branch=default_branch, + node_schema=registry.schema.get_node_schema(name="TestCar", branch=default_branch), + field_name="name", + new_kind="MacAddress", + ) + + assert all_data_paths == { + DataPath( + branch=default_branch.name, + path_type=PathType.ATTRIBUTE, + node_id=car_accord_main.id, + kind="TestCar", + field_name="name", + value="aa-bb-cc-dd-ee-ff", + ) + } + + +async def test_query_text_to_macaddress_with_canonical_value_is_allowed( + db: InfrahubDatabase, default_branch: Branch, car_accord_main: Node +) -> None: + """An already-canonical MacAddress passes, so the check does not block every conversion.""" + car = await NodeManager.get_one(db=db, branch=default_branch, id=car_accord_main.id) + car.get_attribute("name").value = "AA:BB:CC:DD:EE:FF" + await car.save(db=db) + + all_data_paths = await _get_kind_change_data_paths( + db=db, + branch=default_branch, + node_schema=registry.schema.get_node_schema(name="TestCar", branch=default_branch), + field_name="name", + new_kind="MacAddress", + ) + + assert all_data_paths == set() + + async def test_query_update_on_branch_with_parameters_violation( db: InfrahubDatabase, default_branch: Branch, diff --git a/backend/tests/component/core/test_manager_node.py b/backend/tests/component/core/test_manager_node.py index 578c6a9c564..4381f552696 100644 --- a/backend/tests/component/core/test_manager_node.py +++ b/backend/tests/component/core/test_manager_node.py @@ -14,7 +14,7 @@ from infrahub.core.query.node import NodeToProcess from infrahub.core.registry import registry from infrahub.core.relationship import Relationship -from infrahub.core.schema import NodeSchema, SchemaRoot +from infrahub.core.schema import AttributeSchema, NodeSchema, SchemaRoot from infrahub.core.schema.schema_branch import SchemaBranch from infrahub.core.timestamp import Timestamp from infrahub.database import InfrahubDatabase @@ -374,6 +374,30 @@ async def test_iphost_attribute_value_is_normalized_after_save(db: InfrahubDatab assert reloaded.address.value == "192.0.2.10/32" +async def test_ipaddress_attribute_value_is_normalized_after_save(db: InfrahubDatabase, default_branch: Branch) -> None: + """An IPAddress attribute exposes its bare normalized value after a save/reload cycle.""" + schema_root = SchemaRoot( + nodes=[ + NodeSchema( + name="DnsRecord", + namespace="Test", + attributes=[AttributeSchema(name="address", kind="IPAddress")], + ) + ] + ) + registry.schema.register_schema(schema=schema_root, branch=default_branch.name) + + node = await Node.init(db=db, schema="TestDnsRecord", branch=default_branch) + await node.new(db=db, address="2001:0DB8::0001") + await node.save(db=db) + + assert node.get_attribute("address").value == "2001:db8::1" + + reloaded = await NodeManager.get_one(db=db, id=node.id, branch=default_branch) + assert reloaded is not None + assert reloaded.get_attribute("address").value == "2001:db8::1" + + async def test_macaddress_attribute_value_is_normalized_after_save( db: InfrahubDatabase, default_branch: Branch ) -> None: diff --git a/backend/tests/component/core/test_node_query.py b/backend/tests/component/core/test_node_query.py index 72c6e33d85c..dd2e9378f6f 100644 --- a/backend/tests/component/core/test_node_query.py +++ b/backend/tests/component/core/test_node_query.py @@ -77,6 +77,28 @@ async def test_query_NodeCreateAllQuery_iphost( assert await count_nodes(db=db, label="AttributeIPNetwork") == 0 +async def test_query_NodeCreateAllQuery_ipaddress( + db: InfrahubDatabase, default_branch: Branch, all_attribute_types_schema: NodeSchema +) -> None: + """A bare IPAddress value shares the AttributeIPHost vertex shape, without any prefix in its value.""" + obj = await Node.init(db=db, schema="TestAllAttributeTypes", branch=default_branch) + await obj.new(db=db, bare_address="10.2.5.2") + + query = await NodeCreateAllQuery.init(db=db, node=obj, user_id="abcd") + await query.execute(db=db) + + nodes = await get_nodes(db=db, label="AttributeIPHost") + assert len(nodes) == 1 + attribute = nodes[0] + + assert attribute["value"] == "10.2.5.2" + assert attribute["version"] == 4 + assert attribute["binary_address"] == "00001010000000100000010100000010" + assert attribute["prefixlen"] == 32 + + assert await count_nodes(db=db, label="AttributeIPNetwork") == 0 + + async def test_query_NodeCreateAllQuery_ipnetwork( db: InfrahubDatabase, default_branch: Branch, all_attribute_types_schema: NodeSchema ) -> None: diff --git a/backend/tests/component/graphql/test_graphql_query.py b/backend/tests/component/graphql/test_graphql_query.py index ef09074be21..0e1afbb0783 100644 --- a/backend/tests/component/graphql/test_graphql_query.py +++ b/backend/tests/component/graphql/test_graphql_query.py @@ -184,6 +184,7 @@ async def test_all_attributes( myjson={"key1": "bill"}, ipaddress="10.5.0.1/27", prefix="10.1.0.0/22", + bare_address="10.5.0.1", ) await obj1.save(db=db) @@ -212,6 +213,11 @@ async def test_all_attributes( prefixlen netmask } + bare_address { + __typename + value + version + } } } } @@ -244,6 +250,10 @@ async def test_all_attributes( assert results["obj1"]["prefix"]["value"] == obj1.prefix.value assert results["obj1"]["prefix"]["netmask"] == obj1.prefix.netmask assert results["obj1"]["prefix"]["prefixlen"] == obj1.prefix.prefixlen + # a bare address round-trips without gaining a prefix, unlike the IPHost attribute above + assert results["obj1"]["bare_address"]["__typename"] == "IPAddress" + assert results["obj1"]["bare_address"]["value"] == "10.5.0.1" + assert results["obj1"]["bare_address"]["version"] == 4 assert results["obj2"]["mystring"]["value"] == obj2.mystring.value assert results["obj2"]["mybool"]["value"] == obj2.mybool.value @@ -256,6 +266,43 @@ async def test_all_attributes( assert results["obj2"]["prefix"]["value"] == obj2.prefix.value assert results["obj2"]["prefix"]["netmask"] is None assert results["obj2"]["prefix"]["prefixlen"] is None + assert results["obj2"]["bare_address"]["value"] == obj2.bare_address.value + assert results["obj2"]["bare_address"]["version"] is None + + +async def test_ipaddress_attribute_filters( + db: InfrahubDatabase, default_branch: Branch, data_schema: None, all_attribute_types_schema: NodeSchema +) -> None: + """An IPAddress attribute exposes the same filters as IPHost, matching on the normalized value.""" + obj1 = await Node.init(db=db, schema="TestAllAttributeTypes") + await obj1.new(db=db, name="obj1", bare_address="2001:0DB8::0001") + await obj1.save(db=db) + + obj2 = await Node.init(db=db, schema="TestAllAttributeTypes") + await obj2.new(db=db, name="obj2", bare_address="10.0.0.2") + await obj2.save(db=db) + + default_branch.update_schema_hash() + gql_params = await prepare_graphql_params(db=db, branch=default_branch) + + async def names_for(filters: str) -> list[str]: + result = await graphql( + schema=gql_params.schema, + source="query { TestAllAttributeTypes(%s) { edges { node { name { value } } } } }" % filters, + context_value=gql_params.context, + root_value=None, + variable_values={}, + ) + assert result.errors is None + assert result.data + return sorted(item["node"]["name"]["value"] for item in result.data["TestAllAttributeTypes"]["edges"]) + + # the compressed form matches, the expanded input form does not + assert await names_for('bare_address__value: "2001:db8::1"') == ["obj1"] + assert await names_for('bare_address__value: "2001:0DB8::0001"') == [] + assert await names_for('bare_address__values: ["2001:db8::1", "10.0.0.2"]') == ["obj1", "obj2"] + assert await names_for("bare_address__isnull: true") == [] + assert await names_for("bare_address__is_protected: false") == ["obj1", "obj2"] async def test_query_typename(db: InfrahubDatabase, default_branch: Branch, car_person_schema: SchemaBranch) -> None: diff --git a/backend/tests/unit/core/test_attribute.py b/backend/tests/unit/core/test_attribute.py new file mode 100644 index 00000000000..79c26899fce --- /dev/null +++ b/backend/tests/unit/core/test_attribute.py @@ -0,0 +1,129 @@ +import re + +import pytest + +from infrahub.core.attribute import IPAddress, IPAddressOptional +from infrahub.core.branch import Branch +from infrahub.core.node import Node +from infrahub.core.schema import AttributeSchema, NodeSchema +from infrahub.core.timestamp import Timestamp +from infrahub.exceptions import ValidationError + + +@pytest.fixture +def branch() -> Branch: + return Branch(name="main") + + +@pytest.fixture +def ipaddress_schema() -> AttributeSchema: + return AttributeSchema(name="addr", kind="IPAddress") + + +def build_attribute( + schema: AttributeSchema, + branch: Branch, + data: str | None, + attribute_class: type[IPAddress] = IPAddress, +) -> IPAddress: + at = Timestamp() + node_schema = NodeSchema(name="DnsRecord", namespace="Test", attributes=[schema]) + node = Node(schema=node_schema, branch=branch, at=at) + + return attribute_class(name=schema.name, schema=schema, branch=branch, at=at, node=node, data=data) + + +@pytest.mark.parametrize( + "input_value", + [ + "10.0.0.1", + "255.255.255.255", + "0.0.0.0", # noqa: S104 + "2001:db8::1", + "::1", + "::ffff:10.0.0.1", + ], +) +def test_validate_format_ipaddress_accepts_bare_address( + branch: Branch, ipaddress_schema: AttributeSchema, input_value: str +) -> None: + build_attribute(schema=ipaddress_schema, branch=branch, data=input_value) + + +@pytest.mark.parametrize( + "input_value", + [ + "10.0.0.1/32", + "10.0.0.1/24", + "10.0.0.0/255.255.255.0", + "2001:db8::1/128", + "2001:db8::/64", + "010.0.0.1", + "10.0.0.256", + "10.0.1", + "not-an-ip", + "", + ], +) +def test_validate_format_ipaddress_rejects_prefix_and_garbage( + branch: Branch, ipaddress_schema: AttributeSchema, input_value: str +) -> None: + with pytest.raises(ValidationError, match=rf"^{re.escape(input_value)} is not a valid IPAddress at addr$"): + build_attribute(schema=ipaddress_schema, branch=branch, data=input_value) + + +def test_validate_ipaddress_returns(branch: Branch, ipaddress_schema: AttributeSchema) -> None: + test_ipv4 = build_attribute(schema=ipaddress_schema, branch=branch, data="10.0.2.1") + test_ipv6 = build_attribute(schema=ipaddress_schema, branch=branch, data="2001:db8::1") + + assert test_ipv4.value == "10.0.2.1" + assert test_ipv4.version == 4 + assert test_ipv4.ip_integer == 167772673 + assert test_ipv4.ip_binary == "00001010000000000000001000000001" + assert len(test_ipv4.ip_binary) == 32 + assert test_ipv4.to_db() == { + "binary_address": "00001010000000000000001000000001", + "is_default": False, + "prefixlen": 32, + "value": "10.0.2.1", + "version": 4, + } + + assert test_ipv6.value == "2001:db8::1" + assert test_ipv6.version == 6 + assert test_ipv6.ip_integer == 42540766411282592856903984951653826561 + assert test_ipv6.ip_binary == f"00100000000000010000110110111000{'0' * 95}1" + assert len(test_ipv6.ip_binary) == 128 + assert test_ipv6.to_db() == { + "binary_address": f"00100000000000010000110110111000{'0' * 95}1", + "is_default": False, + "prefixlen": 128, + "value": "2001:db8::1", + "version": 6, + } + + +def test_validate_ipaddress_returns_without_value(branch: Branch) -> None: + schema = AttributeSchema(name="addr", kind="IPAddress", optional=True) + + attr = build_attribute(schema=schema, branch=branch, data=None, attribute_class=IPAddressOptional) + + assert attr.value is None + assert attr.version is None + with pytest.raises(ValueError, match=r"^value for IPAddress must be defined$"): + _ = attr.obj + + +@pytest.mark.parametrize( + ("input_value", "normalized_value"), + [ + ("10.0.0.1", "10.0.0.1"), + ("2001:db8::1", "2001:db8::1"), + ("2001:0db8:0000:0000:0000:0000:0000:0001", "2001:db8::1"), + ("2001:DB8::1", "2001:db8::1"), + # an IPv4-mapped address keeps its family rather than collapsing to the IPv4 form + ("::ffff:10.0.0.1", "::ffff:10.0.0.1"), + ], +) +def test_ipaddress_normalizes_value(input_value: str, normalized_value: str) -> None: + assert IPAddress._normalize_value(input_value) == normalized_value diff --git a/backend/tests/unit/test_types.py b/backend/tests/unit/test_types.py index b0ea138a6b6..0a98f15783d 100644 --- a/backend/tests/unit/test_types.py +++ b/backend/tests/unit/test_types.py @@ -3,7 +3,7 @@ from infrahub.core import attribute from infrahub.graphql import types -from infrahub.types import ATTRIBUTE_TYPES +from infrahub.types import ATTRIBUTE_PYTHON_TYPES, ATTRIBUTE_TYPES @pytest.mark.parametrize( @@ -19,7 +19,7 @@ def test_attribute_types_allowed_property_path(test_case: str) -> None: attribute_type = ATTRIBUTE_TYPES[test_case] graphql_query_type = getattr(types, attribute_type.graphql_query) - include_binary_address = test_case in {"IPHost", "IPNetwork"} + include_binary_address = test_case in {"IPAddress", "IPHost", "IPNetwork"} path_list = _get_path_field_list( include_binary_address=include_binary_address, fields=graphql_query_type._meta.fields ) @@ -27,6 +27,11 @@ def test_attribute_types_allowed_property_path(test_case: str) -> None: assert path_list == infrahub_type.get_allowed_property_in_path() +def test_attribute_python_types_cover_every_kind() -> None: + """Every attribute kind needs a python type, the REST schema endpoint looks it up unguarded.""" + assert set(ATTRIBUTE_PYTHON_TYPES) == set(ATTRIBUTE_TYPES) + + def _get_path_field_list(include_binary_address: bool, fields: dict[str, Field]) -> list[str]: """Return list of valid property paths for the specified type.""" excluded_fields = [ diff --git a/changelog/+attribute-kind-change-canonical-values.changed.md b/changelog/+attribute-kind-change-canonical-values.changed.md new file mode 100644 index 00000000000..77dfe589911 --- /dev/null +++ b/changelog/+attribute-kind-change-canonical-values.changed.md @@ -0,0 +1 @@ +Changing an attribute's kind is now rejected when the existing values are not already stored in the canonical form of the new kind. This affects conversions into `IPHost`, `IPNetwork`, `IPAddress` and `MacAddress`. For example converting a `Text` attribute holding `aa-bb-cc-dd-ee-ff` to `MacAddress` is now refused, because the canonical form is `AA:BB:CC:DD:EE:FF`. Normalize the values first, then change the kind. diff --git a/changelog/+ipaddress-attribute-kind.added.md b/changelog/+ipaddress-attribute-kind.added.md new file mode 100644 index 00000000000..037bc240a47 --- /dev/null +++ b/changelog/+ipaddress-attribute-kind.added.md @@ -0,0 +1 @@ +Added a new `IPAddress` attribute kind that stores a bare IP address. Unlike `IPHost`, which normalizes `192.0.2.1` to `192.0.2.1/32`, an `IPAddress` value must not carry a prefix length or netmask, and any value that does is rejected. IPv6 values are normalized to their compressed lowercase form. Note that values sort lexically rather than numerically. diff --git a/dev/knowledge/backend/display-labels-and-hfid.md b/dev/knowledge/backend/display-labels-and-hfid.md index 743fbb4898e..62c597306a6 100644 --- a/dev/knowledge/backend/display-labels-and-hfid.md +++ b/dev/knowledge/backend/display-labels-and-hfid.md @@ -147,10 +147,15 @@ For attribute kinds whose accepted input form differs from their normalized stor |------|-----------------| | `IPHost` | `ipaddress.ip_interface(value).with_prefixlen` (e.g. `192.0.2.1` → `192.0.2.1/32`) | | `IPNetwork` | `ipaddress.ip_network(value).with_prefixlen` (e.g. `2001:db8:0:0::/32` → `2001:db8::/32`) | +| `IPAddress` | `str(ipaddress.ip_address(value))` (e.g. `2001:0DB8::0001` → `2001:db8::1`); a prefix or netmask is rejected outright rather than normalized | | `MacAddress` | `netaddr.EUI(addr=value).format(dialect=netaddr.mac_unix_expanded).upper()` (e.g. `aa-bb-cc-dd-ee-ff` → `AA:BB:CC:DD:EE:FF`) | `_normalize_value()` is intentionally a separate hook from `serialize_value()`. The latter is also used by `HashedPassword` (destructive hash), `ListAttribute`/`JSONAttribute` (type-changing JSON dump), and the base class (Enum unwrap) — transforms that cannot run on `attr.value` itself. For kinds that need input-time normalization, `serialize_value()` delegates to `_normalize_value(self.value)` so the normalized form has a single source of truth per class. When adding a new kind that needs input-time normalization, override `_normalize_value()` (not `serialize_value`). +`_normalize_value()` is a `classmethod` so a value can be checked without building an attribute instance. `AttributeKindUpdateValidatorQuery` relies on that to enforce canonicality when an attribute's kind changes: a kind change runs no data migration over the stored values, so a value that parses under the new kind but is not already in its canonical form would survive un-rewritten and then miss every `__value` filter and uniqueness comparison. The check is unconditional — kinds that do not normalize inherit the identity `_normalize_value()`, so it is a no-op for them and a newly added normalizing kind is covered without touching the validator. + +The practical consequence is that a kind change into a normalizing kind is only allowed when the existing values are already canonical. Converting `Text` → `MacAddress` over `aa-bb-cc-dd-ee-ff` is refused, and `IPAddress` ↔ `IPHost` is refused in both directions because neither side's stored form is canonical for the other. + ## Hierarchical Relationships and Inline Fragments diff --git a/docs/docs/reference/schema/attribute.mdx b/docs/docs/reference/schema/attribute.mdx index d4344a57b6f..8fcae33d6c4 100644 --- a/docs/docs/reference/schema/attribute.mdx +++ b/docs/docs/reference/schema/attribute.mdx @@ -184,7 +184,7 @@ extensions: | **Optional** | False | | **Default Value** | | | **Constraints** | | -| **Accepted Values** | `ID` `Dropdown` `Text` `TextArea` `DateTime` `Email` `Password` `HashedPassword` `URL` `File` `MacAddress` `Color` `Number` `NumberPool` `Bandwidth` `IPHost` `IPNetwork` `Boolean` `Checkbox` `List` `JSON` `Any` | +| **Accepted Values** | `ID` `Dropdown` `Text` `TextArea` `DateTime` `Email` `Password` `HashedPassword` `URL` `File` `MacAddress` `Color` `Number` `NumberPool` `Bandwidth` `IPHost` `IPNetwork` `IPAddress` `Boolean` `Checkbox` `List` `JSON` `Any` | ### label diff --git a/docs/docs/schema/nodes-and-attributes.mdx b/docs/docs/schema/nodes-and-attributes.mdx index 36b92428c63..080d67ddcf8 100644 --- a/docs/docs/schema/nodes-and-attributes.mdx +++ b/docs/docs/schema/nodes-and-attributes.mdx @@ -76,8 +76,9 @@ The `kind` of a model is generated by concatenating the `namespace` and the `nam - `Color`: An HTML color - `Boolean`: Flag that can be either True or False - `Bandwidth`: Bandwidth in kbps -- `IPHost`: IP Address in either IPV4 or IPv6 format +- `IPHost`: IP Address in either IPV4 or IPv6 format, stored with a prefix length (`192.0.2.1` is stored as `192.0.2.1/32`) - `IPNetwork`: IP Network in either IPV4 or IPv6 format +- `IPAddress`: A bare IP Address in either IPv4 or IPv6 format, without a prefix length or netmask. Use this instead of `IPHost` when the prefix is not part of the data, such as a DNS record target or a syslog destination. A value carrying a prefix or mask is rejected, and values sort lexically rather than numerically - `Checkbox`: Duplicate of `Boolean` - `List`: List of any value - `JSON`: Any data structure compatible with JSON @@ -105,6 +106,7 @@ The `kind` of a model is generated by concatenating the `namespace` and the `nam | `Bandwidth` | Yes | Yes | | `IPHost` | Yes | Yes | | `IPNetwork` | Yes | Yes | + | `IPAddress` | Yes | Yes | | `Checkbox` | No | Yes | | `List` | No | Yes | | `JSON` | No | Yes | diff --git a/frontend/app/.betterer.results b/frontend/app/.betterer.results index 882329c42ed..c200e5cfb72 100644 --- a/frontend/app/.betterer.results +++ b/frontend/app/.betterer.results @@ -69,14 +69,14 @@ exports[`fix ts error`] = { "src/entities/navigation/ui/search-anywhere/search-nodes.tsx:3544903153": [ [127, 62, 4, "tsc: Property \'node\' does not exist on type \'NodeAttributeWithMetadata | NodeRelationshipManyWithMetadata | NodeRelationshipOneWithMetadata | string | string[]\'.\\n Property \'node\' does not exist on type \'string\'.", "2087865285"], [136, 16, 5, "tsc: Type \'null | string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "183222373"], - [137, 29, 4, "tsc: Property \'kind\' does not exist on type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; isRelationship: boolean; paginated: boolean; } | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; excluded_values?: string | undefined; identifier?: string | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; isAttribute: boolean; } | undefined; isAttribute: boolean; } | undefined; isAttribute: boolean; } | undefined; isAttribute: boolean; } | undefined; isAttribute: boolean; } | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; state: \\"present\\" | undefined; } | undefined; } | undefined; } | undefined; } | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { label: string; name: string; }\'.\\n Property \'kind\' does not exist on type \'{ label: string; name: string; }\'.", "2088042925"], + [137, 29, 4, "tsc: Property \'kind\' does not exist on type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; isRelationship: boolean; paginated: boolean; } | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; excluded_values?: string | undefined; identifier?: string | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; isAttribute: boolean; } | undefined; isAttribute: boolean; } | undefined; isAttribute: boolean; } | undefined; isAttribute: boolean; } | undefined; isAttribute: boolean; } | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; state: \\"present\\" | undefined; } | undefined; } | undefined; } | undefined; } | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { label: string; name: string; }\'.\\n Property \'kind\' does not exist on type \'{ label: string; name: string; }\'.", "2088042925"], [138, 16, 5, "tsc: Type \'NodeAttributeWithMetadata | NodeCorePropertyValue | NodeRelationshipWithMetadata\' is not assignable to type \'boolean | null; label: string; color: string; } | null; } | number | { edges: { node: NodeCore; }[]; } | { node: NodeCore; } | { value: string | { value: string\'.\\n Type \'undefined\' is not assignable to type \'boolean | null; label: string; color: string; } | null; } | number | { edges: { node: NodeCore; }[]; } | { node: NodeCore; } | { value: string | { value: string\'.", "189936718"] ], "src/entities/nodes/convert/ui/convert-form.tsx:1982113281": [ [41, 6, 8, "tsc: Type \'{}\' is not assignable to type \'Record\'.\\n Index signature for type \'string\' is missing in type \'{}\'.", "1301887866"] ], - "src/entities/nodes/object/ui/filters/dynamic-filter-input.tsx:2811726414": [ - [114, 25, 9, "tsc: Argument of type \'\\"NodeKind\\"\' is not assignable to parameter of type \'never\'.", "2512581423"] + "src/entities/nodes/object/ui/filters/dynamic-filter-input.tsx:2939241983": [ + [115, 25, 9, "tsc: Argument of type \'\\"NodeKind\\"\' is not assignable to parameter of type \'never\'.", "2512581423"] ], "src/entities/nodes/object/ui/object-details/action-buttons/details-buttons.tsx:1531921325": [ [54, 21, 25, "tsc: \'objectDetailsData.targets\' is possibly \'null\' or \'undefined\'.", "1788660526"], @@ -131,7 +131,7 @@ exports[`fix ts error`] = { [93, 52, 4, "tsc: Binding element \'node\' implicitly has an \'any\' type.", "2087865285"] ], "src/entities/repository/ui/repository-objects-manager.tsx:3064139168": [ - [40, 6, 18, "tsc: Type \'\\"Attribute\\" | \\"Attribute\\" | \\"Attribute\\" | \\"Attribute\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Color\\" | \\"Color\\" | \\"Color\\" | \\"Color\\" | \\"Component\\" | \\"Component\\" | \\"Component\\" | \\"Component\\" | \\"DateTime\\" | \\"DateTime\\" | \\"DateTime\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Email\\" | \\"Email\\" | \\"Email\\" | \\"Email\\" | \\"File\\" | \\"File\\" | \\"File\\" | \\"File\\" | \\"Group\\" | \\"Group\\" | \\"Group\\" | \\"Group\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"ID\\" | \\"ID\\" | \\"ID\\" | \\"ID\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"JSON\\" | \\"JSON\\" | \\"JSON\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Parent\\" | \\"Parent\\" | \\"Parent\\" | \\"Password\\" | \\"Password\\" | \\"Password\\" | \\"Password\\" | \\"Profile\\" | \\"Profile\\" | \\"Profile\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"cascade\\" | \\"cascade\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | \\"outbound\\" | \\"outbound\\" | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_label?: string | undefined; display_label?: string | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; documentation?: string | undefined; documentation?: string | undefined; documentation?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; hash?: string | undefined; hash?: string | undefined; hash?: string | undefined; hierarchical: boolean; generate_profile: boolean; used_by?: string[] | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; icon?: string | undefined; icon?: string | undefined; icon?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; restricted_namespaces?: string[] | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; })[] | undefined; })[] | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is not assignable to type \'ModelSchema\'.\\n Type \'null\' is not assignable to type \'ModelSchema\'.", "3283107600"] + [40, 6, 18, "tsc: Type \'\\"Attribute\\" | \\"Attribute\\" | \\"Attribute\\" | \\"Attribute\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Color\\" | \\"Color\\" | \\"Color\\" | \\"Color\\" | \\"Component\\" | \\"Component\\" | \\"Component\\" | \\"Component\\" | \\"DateTime\\" | \\"DateTime\\" | \\"DateTime\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Email\\" | \\"Email\\" | \\"Email\\" | \\"Email\\" | \\"File\\" | \\"File\\" | \\"File\\" | \\"File\\" | \\"Group\\" | \\"Group\\" | \\"Group\\" | \\"Group\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"ID\\" | \\"ID\\" | \\"ID\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPAddress\\" | \\"IPAddress\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"JSON\\" | \\"JSON\\" | \\"JSON\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Parent\\" | \\"Parent\\" | \\"Parent\\" | \\"Password\\" | \\"Password\\" | \\"Password\\" | \\"Password\\" | \\"Profile\\" | \\"Profile\\" | \\"Profile\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"cascade\\" | \\"cascade\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | \\"outbound\\" | \\"outbound\\" | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_label?: string | undefined; display_label?: string | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; documentation?: string | undefined; documentation?: string | undefined; documentation?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; hash?: string | undefined; hash?: string | undefined; hash?: string | undefined; hierarchical: boolean; generate_profile: boolean; used_by?: string[] | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; icon?: string | undefined; icon?: string | undefined; icon?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; restricted_namespaces?: string[] | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; })[] | undefined; })[] | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is not assignable to type \'ModelSchema\'.\\n Type \'null\' is not assignable to type \'ModelSchema\'.", "3283107600"] ], "src/entities/resource-manager/ui/number-pool-form.tsx:3337327002": [ [50, 39, 13, "tsc: Argument of type \'NodeFieldsWithMetadata | undefined\' is not assignable to parameter of type \'Record | undefined\'.\\n Type \'NodeFieldsWithMetadata\' is not assignable to type \'Record\'.\\n \'string\' index signatures are incompatible.\\n Type \'NodeAttributeWithMetadata | NodeCorePropertyValue | NodeRelationshipWithMetadata\' is not assignable to type \'NodeAttributeWithMetadata\'.\\n Type \'undefined\' is not assignable to type \'NodeAttributeWithMetadata\'.", "3940586649"], @@ -258,9 +258,9 @@ exports[`fix ts error`] = { "src/shared/components/errors/error-boundary-app.tsx:3236611445": [ [5, 24, 5, "tsc: Type \'unknown\' is not assignable to type \'Error\'.", "165548477"] ], - "src/shared/components/form/dynamic-form.tsx:3819858559": [ + "src/shared/components/form/dynamic-form.tsx:3408699566": [ [98, 14, 11, "tsc: Type \'\\"setValueAs\\" | \\"setValueAs\\" | \\"valueAsDate\\"> | \\"valueAsDate\\"> | \\"valueAsNumber\\" | \\"valueAsNumber\\" | undefined; defaultValue?: FormAttributeValue | undefined; defaultValue?: FormAttributeValue | undefined; description?: string | undefined; description?: string | undefined; disabled?: boolean | undefined; disabled?: boolean | undefined; isBulkUpdate?: boolean | undefined; isBulkUpdate?: boolean | undefined; label?: string | undefined; label?: string | undefined; name: string; placeholder?: string | undefined; name: string; placeholder?: string | undefined; onChange?: ((value: FormFieldValue) => void) | undefined; onChange?: ((value: FormFieldValue) => void) | undefined; pool?: { kind: string; defaultAllocatedObjectKind: string; fromPoolRelationshipName?: string | undefined; pool?: { kind: string; defaultAllocatedObjectKind: string; fromPoolRelationshipName?: string | undefined; pools?: NumberPool[] | undefined; rules?: Omit, \\"disabled\\" | undefined; rules?: Omit, \\"disabled\\" | undefined; shouldUnregister?: boolean | undefined; shouldUnregister?: boolean | undefined; unique?: boolean | undefined; unique?: boolean | undefined; } | undefined; } | undefined; } | undefined; } | { attribute?: AttributeSchema | { attribute?: AttributeSchema\' is not assignable to type \'IntrinsicAttributes & NumberFieldProps\'.\\n Type \'\\"setValueAs\\" | \\"valueAsDate\\"> | \\"valueAsNumber\\" | undefined; defaultValue?: FormAttributeValue | undefined; description?: string | undefined; disabled?: boolean | undefined; isBulkUpdate?: boolean | undefined; label?: string | undefined; name: string; placeholder?: string | undefined; onChange?: ((value: FormFieldValue) => void) | undefined; pool?: { kind: string; defaultAllocatedObjectKind: string; fromPoolRelationshipName?: string | undefined; rules?: Omit, \\"disabled\\" | undefined; shouldUnregister?: boolean | undefined; unique?: boolean | undefined; } | undefined; } | { attribute?: AttributeSchema\' is not assignable to type \'NumberFieldProps\'.\\n Types of property \'onChange\' are incompatible.\\n Type \'((value: FormFieldValue) => void) | undefined\' is not assignable to type \'ChangeEventHandler | undefined\'.\\n Type \'(value: FormFieldValue) => void\' is not assignable to type \'ChangeEventHandler\'.\\n Types of parameters \'value\' and \'event\' are incompatible.\\n Type \'ChangeEvent\' is not assignable to type \'FormFieldValue\'.", "588154884"], - [148, 25, 5, "tsc: Argument of type \'DynamicKindFieldProps\' is not assignable to parameter of type \'never\'.", "187023499"] + [149, 25, 5, "tsc: Argument of type \'DynamicKindFieldProps\' is not assignable to parameter of type \'never\'.", "187023499"] ], "src/shared/components/form/fields/color.field.tsx:220004711": [ [9, 17, 15, "tsc: Interface \'InputFieldProps\' cannot simultaneously extend types \'FormFieldProps\' and \'\\"name\\"> | Omit | Omit | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'", "2303311585"], + [33, 4, 12, "tsc: Type \'{ kind: string; }\' is not assignable to type \'ModelSchema\'.\\n Type \'{ kind: string; }\' is missing 4 properties from type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'", "2303311585"], [56, 64, 21, "tsc: Argument of type \'NodeCore[] | PoolSource | TemplateSource | null | null | null | undefined; display_label: string | undefined; value: { id: NodeCore | undefined; }; } | { from_pool: { id: string; }; } | { source: ProfileSource | { type: SourceType; } | { type: SourceType; }\' is not assignable to parameter of type \'FormRelationshipValue | undefined\'.\\n Type \'NodeCore[] | PoolSource | TemplateSource | null | null | null | undefined; display_label: string | undefined; value: { id: NodeCore | undefined; }; } | { from_pool: { id: string; }; } | { source: ProfileSource | { type: SourceType; } | { type: SourceType; }\' is not assignable to type \'EmptyFieldValue | RelationshipManyValueFromProfile | RelationshipManyValueFromTemplate | RelationshipManyValueFromUser | RelationshipOneValueFromProfile | RelationshipOneValueFromTemplate | RelationshipOneValueFromUser | RelationshipValueFromPool\'.\\n Type \'NodeCore[] | PoolSource | TemplateSource | null | null | null | undefined; display_label: string | undefined; value: { id: NodeCore | undefined; }; } | { from_pool: { id: string; }; } | { source: ProfileSource | { type: SourceType; } | { type: SourceType; }\' is not assignable to type \'RelationshipManyValueFromProfile\'.\\n Types of property \'source\' are incompatible.\\n Type \'PoolSource | ProfileSource | TemplateSource | null | undefined | { type: SourceType; } | { type: SourceType; }\' is not assignable to type \'ProfileSource\'.\\n Type \'undefined\' is not assignable to type \'ProfileSource\'.", "2333515987"], [63, 48, 13, "tsc: Property \'display_label\' does not exist on type \'NodeCore | { from_pool: { id: string; }; }\'.\\n Property \'display_label\' does not exist on type \'{ from_pool: { id: string; }; }\'.", "1907695430"], [84, 18, 4, "tsc: Type \'string | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "2088092007"] @@ -296,7 +296,7 @@ exports[`fix ts error`] = { [231, 54, 4, "tsc: Property \'name\' does not exist on type \'\\"\\" | \\"Attribute\\" | \\"Component\\" | \\"Group\\" | \\"Hierarchy\\" | \\"Parent\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"cascade\\" | \\"extra\\"; } | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; cardinality: \\"one\\" | undefined; common_relatives?: string[] | undefined; description?: string | undefined; display: \\"default\\" | undefined; identifier?: string | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_weight?: number | undefined; state: \\"present\\" | { id?: string\'.\\n Property \'name\' does not exist on type \'\\"\\"\'.", "2087876002"] ], "src/shared/components/form/file-with-profile-form.tsx:3973907063": [ - [13, 8, 6, "tsc: Type \'\\"Attribute\\" | \\"Attribute\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Color\\" | \\"Color\\" | \\"Component\\" | \\"Component\\" | \\"DateTime\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Email\\" | \\"Email\\" | \\"File\\" | \\"File\\" | \\"Group\\" | \\"Group\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"ID\\" | \\"ID\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"JSON\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Parent\\" | \\"Password\\" | \\"Password\\" | \\"Profile\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; documentation?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; icon?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is not assignable to type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'.\\n Type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is missing 2 properties from type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'", "2166186324"] + [13, 8, 6, "tsc: Type \'\\"Attribute\\" | \\"Attribute\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Color\\" | \\"Color\\" | \\"Component\\" | \\"Component\\" | \\"DateTime\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Email\\" | \\"Email\\" | \\"File\\" | \\"File\\" | \\"Group\\" | \\"Group\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"ID\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"JSON\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Parent\\" | \\"Password\\" | \\"Password\\" | \\"Profile\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; documentation?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; icon?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is not assignable to type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'.\\n Type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is missing 2 properties from type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'", "2166186324"] ], "src/shared/components/form/generic-object-form.tsx:1723799544": [ [14, 4, 69, "tsc: Argument of type \'null | string | undefined\' is not assignable to parameter of type \'(() => string | null | null) | string\'.\\n Type \'undefined\' is not assignable to type \'(() => string | null | null) | string\'.", "2587726796"] @@ -306,10 +306,10 @@ exports[`fix ts error`] = { [87, 8, 5, "tsc: Type \'(null | undefined; badge: string; } | { value: string; label: string | { value: string; label: string; })[]\' is not assignable to type \'DropdownOption[]\'.\\n Type \'null | undefined; badge: string; } | { value: string; label: string | { value: string; label: string; }\' is not assignable to type \'DropdownOption\'.\\n Type \'{ value: string; label: null | string | undefined; badge: string; }\' is not assignable to type \'DropdownOption\'.\\n Types of property \'label\' are incompatible.\\n Type \'string | null | undefined\' is not assignable to type \'string\'.\\n Type \'undefined\' is not assignable to type \'string\'.", "179721187"] ], "src/shared/components/form/node-with-profile-form.tsx:1982864313": [ - [12, 8, 6, "tsc: Type \'\\"Attribute\\" | \\"Attribute\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Color\\" | \\"Color\\" | \\"Component\\" | \\"Component\\" | \\"DateTime\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Email\\" | \\"Email\\" | \\"File\\" | \\"File\\" | \\"Group\\" | \\"Group\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"ID\\" | \\"ID\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"JSON\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Parent\\" | \\"Password\\" | \\"Password\\" | \\"Profile\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; documentation?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; icon?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is not assignable to type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'.\\n Type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is missing 2 properties from type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'", "2166186324"] + [12, 8, 6, "tsc: Type \'\\"Attribute\\" | \\"Attribute\\" | \\"Bandwidth\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Checkbox\\" | \\"Color\\" | \\"Color\\" | \\"Component\\" | \\"Component\\" | \\"DateTime\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Dropdown\\" | \\"Email\\" | \\"Email\\" | \\"File\\" | \\"File\\" | \\"Group\\" | \\"Group\\" | \\"HashedPassword\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"Hierarchy\\" | \\"ID\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"JSON\\" | \\"MacAddress\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Parent\\" | \\"Password\\" | \\"Password\\" | \\"Profile\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; documentation?: string | undefined; excluded_values?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; icon?: string | undefined; identifier?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is not assignable to type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'.\\n Type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\' is missing 2 properties from type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; children?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; generate_profile: boolean; generate_template: boolean; hierarchy?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; parent?: string | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'", "2166186324"] ], "src/shared/components/form/utils/shouldAllowEmptySubmission.test.ts:2916106780": [ - [61, 19, 44, "tsc: Conversion of type \'{ attributes: never[]; }\' to type \'ModelSchema\' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to \'unknown\' first.\\n Type \'{ attributes: never[]; }\' is missing 5 properties from type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'", "1893999184"] + [61, 19, 44, "tsc: Conversion of type \'{ attributes: never[]; }\' to type \'ModelSchema\' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to \'unknown\' first.\\n Type \'{ attributes: never[]; }\' is missing 5 properties from type \'\\"Attribute\\" | \\"Bandwidth\\" | \\"Boolean\\" | \\"Checkbox\\" | \\"Color\\" | \\"Component\\" | \\"DateTime\\" | \\"Dropdown\\" | \\"Email\\" | \\"File\\" | \\"Group\\" | \\"HashedPassword\\" | \\"Hierarchy\\" | \\"ID\\" | \\"IPAddress\\" | \\"IPHost\\" | \\"IPNetwork\\" | \\"JSON\\" | \\"MacAddress\\" | \\"Parent\\" | \\"Password\\" | \\"Profile\\" | \\"Template\\"; label?: string | \\"TextArea\\"; enum?: unknown[] | \\"URL\\"; enum?: unknown[] | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; allow_override: \\"none\\" | \\"absent\\"; attributes?: ({ id?: string | \\"absent\\"; on_delete?: \\"no-action\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\" | \\"agnostic\\"; default_filter?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; deprecation?: string | \\"any\\"; read_only: boolean; deprecation?: string | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"aware\\" | \\"cascade\\" | \\"extra\\"; parameters?: Record | \\"extra\\"; parameters?: { end_range: number; start_range: number; number_pool_id?: string | \\"extra\\"; parameters?: { min_value?: number | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; parameters?: { regex?: string | \\"extra\\"; }[] | \\"inbound\\"; hierarchical?: string | \\"many\\"; min_count: number; max_count: number; common_parent?: string | \\"outbound\\" | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | null | undefined; allow_override: \\"none\\" | undefined; branch: \\"local\\" | undefined; cardinality: \\"one\\" | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; choices?: { name: string; description?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; color?: string | undefined; common_relatives?: string[] | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; computed_attribute?: { kind: \\"Jinja2\\"; jinja2_template: string; } | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; description?: string | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display: \\"default\\" | undefined; display_label?: string | undefined; display_labels?: string[] | undefined; documentation?: string | undefined; excluded_values?: string | undefined; hash?: string | undefined; human_friendly_id?: string[] | undefined; icon?: string | undefined; identifier?: string | undefined; include_in_menu?: boolean | undefined; inherit_from?: string[] | undefined; inherited: boolean; direction: \\"bidirectional\\" | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; label?: string | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_length?: number | undefined; max_value?: number | undefined; menu_placement?: string | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; min_length?: number | undefined; name: string; kind: \\"Any\\" | undefined; name: string; kind: \\"List\\"; enum?: unknown[] | undefined; name: string; kind: \\"Number\\"; enum?: unknown[] | undefined; name: string; kind: \\"NumberPool\\"; enum?: unknown[] | undefined; name: string; kind: \\"Text\\" | undefined; name: string; namespace: string; description?: string | undefined; name: string; peer: string; kind: \\"Generic\\" | undefined; optional: boolean; branch?: \\"local\\" | undefined; order_by?: string[] | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; order_weight?: number | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; ordered: boolean; default_value?: unknown; inherited: boolean; state: \\"present\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; read_only: boolean; unique: boolean; optional: boolean; branch?: \\"local\\" | undefined; readonly kind: string; } | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; regex?: string | undefined; relationships?: { id?: string | undefined; state: \\"present\\" | undefined; state: \\"present\\" | undefined; uniqueness_constraints?: string[][] | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; } | undefined; })[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | undefined; }[] | { id?: string | { id?: string | { id?: string | { id?: string | { id?: string | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"TransformPython\\"; transform: string; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; } | { kind: \\"User\\"; }\'", "1893999184"] ], "src/shared/components/form/utils/updateFormFieldValue.ts:2093845453": [ [45, 30, 8, "tsc: Argument of type \'null | { id: string; } | { id: string; }[]\' is not assignable to parameter of type \'NodeCore | NodeCore[] | boolean | null | number | string | string[]\'.\\n Type \'{ id: string; }\' is not assignable to type \'NodeCore | NodeCore[] | boolean | null | number | string | string[]\'.\\n Property \'__typename\' is missing in type \'{ id: string; }\' but required in type \'NodeCore\'.", "288015442"], diff --git a/frontend/app/src/entities/nodes/getObjectItemDisplayValue.tsx b/frontend/app/src/entities/nodes/getObjectItemDisplayValue.tsx index f150a98e452..444b1f19dcc 100644 --- a/frontend/app/src/entities/nodes/getObjectItemDisplayValue.tsx +++ b/frontend/app/src/entities/nodes/getObjectItemDisplayValue.tsx @@ -208,6 +208,7 @@ export const ObjectAttributeValue = ({ case ATTRIBUTE_KIND.FILE: case ATTRIBUTE_KIND.IP_HOST: case ATTRIBUTE_KIND.IP_NETWORK: + case ATTRIBUTE_KIND.IP_ADDRESS: case ATTRIBUTE_KIND.ANY: return {getTextValue(attributeData).toString()}; case ATTRIBUTE_KIND.URL: diff --git a/frontend/app/src/entities/nodes/object/domain/rules/get-attributes-visible-in-list-view.test.ts b/frontend/app/src/entities/nodes/object/domain/rules/get-attributes-visible-in-list-view.test.ts index 1a8b6815596..9236f87feb9 100644 --- a/frontend/app/src/entities/nodes/object/domain/rules/get-attributes-visible-in-list-view.test.ts +++ b/frontend/app/src/entities/nodes/object/domain/rules/get-attributes-visible-in-list-view.test.ts @@ -25,6 +25,7 @@ describe("getAttributesVisibleInListView", () => { generateAttributeSchema({ name: "bandwidth", kind: "Bandwidth", label: "Bandwidth" }), generateAttributeSchema({ name: "iphost", kind: "IPHost", label: "IPHost" }), generateAttributeSchema({ name: "ipnetwork", kind: "IPNetwork", label: "IPNetwork" }), + generateAttributeSchema({ name: "ipaddress", kind: "IPAddress", label: "IPAddress" }), generateAttributeSchema({ name: "checkbox", kind: "Checkbox", label: "Checkbox" }), generateAttributeSchema({ name: "list", kind: "List", label: "List" }), generateAttributeSchema({ name: "json", kind: "JSON", label: "JSON" }), @@ -49,6 +50,7 @@ describe("getAttributesVisibleInListView", () => { "Bandwidth", "IPHost", "IPNetwork", + "IPAddress", ]); }); diff --git a/frontend/app/src/entities/nodes/object/ui/filters/dynamic-filter-input.tsx b/frontend/app/src/entities/nodes/object/ui/filters/dynamic-filter-input.tsx index bb1d9519e5f..17c40f57ad6 100644 --- a/frontend/app/src/entities/nodes/object/ui/filters/dynamic-filter-input.tsx +++ b/frontend/app/src/entities/nodes/object/ui/filters/dynamic-filter-input.tsx @@ -47,6 +47,7 @@ export function DynamicFilterInput({ fieldSchema, value, onChange }: DynamicFilt case ATTRIBUTE_KIND.MAC_ADDRESS: case ATTRIBUTE_KIND.IP_HOST: case ATTRIBUTE_KIND.IP_NETWORK: + case ATTRIBUTE_KIND.IP_ADDRESS: case ATTRIBUTE_KIND.PASSWORD: case ATTRIBUTE_KIND.HASHED_PASSWORD: case ATTRIBUTE_KIND.URL: diff --git a/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-attribute-cell.tsx b/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-attribute-cell.tsx index 7c50ded83e5..4b94c172fa8 100644 --- a/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-attribute-cell.tsx +++ b/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-attribute-cell.tsx @@ -48,6 +48,7 @@ export function TableAttributeCell({ attributeSchema, attributeData }: TableAttr case ATTRIBUTE_KIND.FILE: case ATTRIBUTE_KIND.IP_HOST: case ATTRIBUTE_KIND.IP_NETWORK: + case ATTRIBUTE_KIND.IP_ADDRESS: case ATTRIBUTE_KIND.NODE_KIND: case ATTRIBUTE_KIND.TEXTAREA: { if (attributeSchema.name === "node_kind") { diff --git a/frontend/app/src/entities/schema/domain/model/attribute-kind.ts b/frontend/app/src/entities/schema/domain/model/attribute-kind.ts index d085c1a4c8e..a7ecad8cd51 100644 --- a/frontend/app/src/entities/schema/domain/model/attribute-kind.ts +++ b/frontend/app/src/entities/schema/domain/model/attribute-kind.ts @@ -17,6 +17,7 @@ export const ATTRIBUTE_KIND = { BANDWIDTH: "Bandwidth", IP_HOST: "IPHost", IP_NETWORK: "IPNetwork", + IP_ADDRESS: "IPAddress", CHECKBOX: "Checkbox", LIST: "List", JSON: "JSON", @@ -39,5 +40,6 @@ export const ATTRIBUTE_KINDS_FOR_LIST_VIEW: readonly AttributeKind[] = [ ATTRIBUTE_KIND.BANDWIDTH, ATTRIBUTE_KIND.IP_HOST, ATTRIBUTE_KIND.IP_NETWORK, + ATTRIBUTE_KIND.IP_ADDRESS, ATTRIBUTE_KIND.DATETIME, ]; diff --git a/frontend/app/src/entities/schema/domain/rules/validation/validate-ip-address-attribute.test.ts b/frontend/app/src/entities/schema/domain/rules/validation/validate-ip-address-attribute.test.ts new file mode 100644 index 00000000000..119e99874a9 --- /dev/null +++ b/frontend/app/src/entities/schema/domain/rules/validation/validate-ip-address-attribute.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { validateIpAddressAttribute } from "./validate-ip-address-attribute"; + +describe("validateIpAddressAttribute", () => { + it.each([ + "10.0.0.1", + "0.0.0.0", + "255.255.255.255", + "2001:db8::1", + "::1", + "::", + "::ffff:10.0.0.1", + "1:2:3:4:5:6:7:8", + // a trailing dotted quad carries the last two groups, so this is a full address + "1:2:3:4:5:6:1.2.3.4", + "::1.2.3.4", + // "::" may stand for a single group of zeros + "1:2:3:4:5:6:7::", + ])("accepts the bare address %s", (value) => { + expect(validateIpAddressAttribute({}, value)).toEqual({ success: true, data: value }); + }); + + it.each([ + "10.0.0.1/32", + "10.0.0.1/24", + "10.0.0.0/255.255.255.0", + "2001:db8::1/128", + ])("rejects %s for carrying a prefix", (value) => { + expect(validateIpAddressAttribute({}, value)).toEqual({ + success: false, + error: "Must be a bare IP address, without a prefix or netmask", + }); + }); + + it.each([ + "010.0.0.1", + "10.0.0.256", + "10.0.1", + "not-an-ip", + "2001:db8::1::2", + "12345::1", + "1:2:3:4:5:6:7:8:9", + // a trailing dotted quad counts as two groups, so these overflow + "1:2:3:4:5:6:7:1.2.3.4", + "1.2.3.4::1", + // "::" has to stand for at least one group, leaving no room after eight + "::1:2:3:4:5:6:7:8", + "1:2:3:4:5:6:7:8::", + ])("rejects %s as malformed", (value) => { + expect(validateIpAddressAttribute({}, value)).toEqual({ + success: false, + error: "Must be a valid IPv4 or IPv6 address", + }); + }); + + it("treats an empty value as valid when the attribute is optional", () => { + expect(validateIpAddressAttribute({}, "")).toEqual({ success: true, data: "" }); + expect(validateIpAddressAttribute({}, null)).toEqual({ success: true, data: "" }); + }); + + it("requires a value when the attribute is mandatory", () => { + expect(validateIpAddressAttribute({ isRequired: true }, "")).toEqual({ + success: false, + error: "Required", + }); + }); +}); diff --git a/frontend/app/src/entities/schema/domain/rules/validation/validate-ip-address-attribute.ts b/frontend/app/src/entities/schema/domain/rules/validation/validate-ip-address-attribute.ts new file mode 100644 index 00000000000..1718ce5a5f7 --- /dev/null +++ b/frontend/app/src/entities/schema/domain/rules/validation/validate-ip-address-attribute.ts @@ -0,0 +1,62 @@ +const IPV4_PATTERN = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/; + +function isValidIpv4(value: string): boolean { + const match = IPV4_PATTERN.exec(value); + if (!match) return false; + + return match.slice(1).every((octet) => { + // a leading zero is ambiguous between decimal and octal, so the backend rejects it too + if (octet.length > 1 && octet.startsWith("0")) return false; + return Number(octet) <= 255; + }); +} + +const HEX_GROUP = /^[0-9a-fA-F]{1,4}$/; + +function isValidIpv6(value: string): boolean { + // At most one "::" run, and it is what allows fewer than the full eight groups. + const runs = value.split("::"); + if (runs.length > 2) return false; + const isCompressed = runs.length === 2; + + const head = runs[0] ?? ""; + const tail = runs[1] ?? ""; + const headParts = head === "" ? [] : head.split(":"); + const tailParts = tail === "" ? [] : tail.split(":"); + const parts = [...headParts, ...tailParts]; + + const last = parts.at(-1); + const endsWithDottedQuad = last !== undefined && !HEX_GROUP.test(last); + if (endsWithDottedQuad && !isValidIpv4(last)) return false; + + const hexParts = endsWithDottedQuad ? parts.slice(0, -1) : parts; + if (hexParts.some((part) => !HEX_GROUP.test(part))) return false; + + // A trailing dotted quad carries 32 bits, so it stands for the final two groups. + const groupCount = parts.length + (endsWithDottedQuad ? 1 : 0); + + // "::" stands for at least one group of zeros, so the explicit groups must leave room for it. + return isCompressed ? groupCount <= 7 : groupCount === 8; +} + +export function validateIpAddressAttribute( + { isRequired = false }: { isRequired?: boolean }, + value: string | null | undefined +): { success: true; data: string } | { success: false; error: string } { + if (!value) { + return isRequired ? { success: false, error: "Required" } : { success: true, data: "" }; + } + + if (value.includes("/")) { + return { + success: false, + error: "Must be a bare IP address, without a prefix or netmask", + }; + } + + if (!isValidIpv4(value) && !isValidIpv6(value)) { + return { success: false, error: "Must be a valid IPv4 or IPv6 address" }; + } + + return { success: true, data: value }; +} diff --git a/frontend/app/src/entities/schema/ui/field-schema-icon.tsx b/frontend/app/src/entities/schema/ui/field-schema-icon.tsx index cb542a6333b..052b1682982 100644 --- a/frontend/app/src/entities/schema/ui/field-schema-icon.tsx +++ b/frontend/app/src/entities/schema/ui/field-schema-icon.tsx @@ -26,6 +26,7 @@ export const ATTRIBUTE_ICONS: Record = { Bandwidth: "mdi:gauge", IPHost: "mdi:ip-network-outline", IPNetwork: "mdi:ip-network-outline", + IPAddress: "mdi:ip-outline", Checkbox: "mdi:checkbox-marked-circle-outline", List: "mdi:format-list-bulleted-square", JSON: "mdi:code-json", diff --git a/frontend/app/src/shared/api/graphql/generated/graphql-cache.d.ts b/frontend/app/src/shared/api/graphql/generated/graphql-cache.d.ts index 768f501af17..fe65f17ad0f 100644 --- a/frontend/app/src/shared/api/graphql/generated/graphql-cache.d.ts +++ b/frontend/app/src/shared/api/graphql/generated/graphql-cache.d.ts @@ -24,199 +24,199 @@ import { refreshAccessTokenQueryOptions } from "@/entities/authentication/ui/que declare module 'gql.tada' { interface setupCache { - /** @gql.tada/hash sha256:b9518bf77f7eac981ea21a421adcc982 */ + /** @gql.tada/hash sha256:6376f0c50c7b9efc9b14b0ed96ef0d5d */ "\n mutation BRANCH_CREATE($name: String!, $description: String, $sync_with_git: Boolean) {\n BranchCreate(data: { name: $name, description: $description, sync_with_git: $sync_with_git }) {\n object {\n id\n name\n description\n origin_branch\n branched_from\n created_at\n status\n sync_with_git\n is_default\n status\n has_schema_changes\n }\n }\n }\n": TadaDocumentNode<{ BranchCreate: { object: { id: string; name: string; description: string | null; origin_branch: string | null; branched_from: string | null; created_at: string | null; status: "DELETING" | "MERGED" | "MERGE_FAILED" | "MERGING" | "NEED_REBASE" | "NEED_UPGRADE_REBASE" | "OPEN"; sync_with_git: boolean | null; is_default: boolean | null; has_schema_changes: boolean | null; } | null; } | null; }, { sync_with_git?: boolean | null | undefined; description?: string | null | undefined; name: string; }, void>; - /** @gql.tada/hash sha256:62c12fc3bb1d5a7dcfacab297beb886b */ + /** @gql.tada/hash sha256:f5d5f680ab0073cb336b18c9ef53c662 */ "\n mutation BRANCH_DELETE($name: String, $deleteFromGit: Boolean) {\n BranchDelete(data: { name: $name, delete_from_git: $deleteFromGit }) {\n ok\n }\n }\n": TadaDocumentNode<{ BranchDelete: { ok: boolean | null; } | null; }, { deleteFromGit?: boolean | null | undefined; name?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:0f1d712fac5036b9320af2246f8be357 */ + /** @gql.tada/hash sha256:6e993f4bc5e40aa1eea81d1f8bf71365 */ "\n query GET_BRANCH_ACTION_STATE($branch: String!, $workflow: [String], $state: [StateType]) {\n InfrahubTask(branch: $branch, workflow: $workflow, state: $state) {\n count\n }\n }\n": TadaDocumentNode<{ InfrahubTask: { count: number; }; }, { state?: ("CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null)[] | null | undefined; workflow?: (string | null)[] | null | undefined; branch: string; }, void>; - /** @gql.tada/hash sha256:b3174e81ac6fb64d9577ab170db5c6e1 */ + /** @gql.tada/hash sha256:8985cd49104dc846e8edfec3cf6e2ee5 */ "\n query GetBranchDetails($branchName: String!) {\n InfrahubBranch(name__value: $branchName) {\n edges {\n node {\n id\n name {\n value\n }\n description {\n value\n }\n origin_branch {\n value\n }\n branched_from {\n value\n }\n status {\n value\n }\n created_at\n sync_with_git {\n value\n }\n is_default {\n value\n }\n has_schema_changes {\n value\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubBranch: { edges: { node: { id: string; name: { value: string; }; description: { value: string | null; } | null; origin_branch: { value: string | null; } | null; branched_from: { value: string | null; } | null; status: { value: "DELETING" | "MERGED" | "MERGE_FAILED" | "MERGING" | "NEED_REBASE" | "NEED_UPGRADE_REBASE" | "OPEN"; }; created_at: string | null; sync_with_git: { value: boolean | null; } | null; is_default: { value: boolean | null; } | null; has_schema_changes: { value: boolean | null; } | null; }; }[]; }; }, { branchName: string; }, void>; - /** @gql.tada/hash sha256:632d9da3f5712d2da4d0b219921736ee */ + /** @gql.tada/hash sha256:503cfe90eaed9377db64a3b6e81e4c69 */ "\n query GetBranchesCount($nameValue: String, $partialMatch: Boolean, $statusValue: BranchStatus, $createdById: ID, $branchedFromAfter: DateTime, $branchedFromBefore: DateTime, $createdAtAfter: DateTime, $createdAtBefore: DateTime, $updatedAtAfter: DateTime, $updatedAtBefore: DateTime) {\n InfrahubBranch(name__value: $nameValue, partial_match: $partialMatch, status__value: $statusValue, node_metadata__created_by__id: $createdById, branched_from__after: $branchedFromAfter, branched_from__before: $branchedFromBefore, node_metadata__created_at__after: $createdAtAfter, node_metadata__created_at__before: $createdAtBefore, node_metadata__updated_at__after: $updatedAtAfter, node_metadata__updated_at__before: $updatedAtBefore) {\n count\n }\n }\n": TadaDocumentNode<{ InfrahubBranch: { count: number | null; }; }, { updatedAtBefore?: unknown; updatedAtAfter?: unknown; createdAtBefore?: unknown; createdAtAfter?: unknown; branchedFromBefore?: unknown; branchedFromAfter?: unknown; createdById?: string | null | undefined; statusValue?: "DELETING" | "MERGED" | "MERGE_FAILED" | "MERGING" | "NEED_REBASE" | "NEED_UPGRADE_REBASE" | "OPEN" | null | undefined; partialMatch?: boolean | null | undefined; nameValue?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:491d94d63b86c8edcaa61a322d175cf1 */ + /** @gql.tada/hash sha256:2bd8c5324846f9ec6199e78f2eed6826 */ "\n query GetBranches($limit: Int, $offset: Int, $nameValue: String, $partialMatch: Boolean, $statusValue: BranchStatus, $createdById: ID, $branchedFromAfter: DateTime, $branchedFromBefore: DateTime, $createdAtAfter: DateTime, $createdAtBefore: DateTime, $updatedAtAfter: DateTime, $updatedAtBefore: DateTime) {\n InfrahubBranch(limit: $limit, offset: $offset, name__value: $nameValue, partial_match: $partialMatch, status__value: $statusValue, node_metadata__created_by__id: $createdById, branched_from__after: $branchedFromAfter, branched_from__before: $branchedFromBefore, node_metadata__created_at__after: $createdAtAfter, node_metadata__created_at__before: $createdAtBefore, node_metadata__updated_at__after: $updatedAtAfter, node_metadata__updated_at__before: $updatedAtBefore) {\n edges {\n node {\n id\n name {\n value\n }\n description {\n value\n }\n origin_branch {\n value\n }\n branched_from {\n value\n }\n status {\n value\n }\n created_at\n sync_with_git {\n value\n }\n is_default {\n value\n }\n has_schema_changes {\n value\n }\n }\n node_metadata {\n created_at\n created_by {\n id\n display_label\n hfid\n __typename\n }\n updated_at\n updated_by {\n id\n display_label\n hfid\n __typename\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubBranch: { edges: { node: { id: string; name: { value: string; }; description: { value: string | null; } | null; origin_branch: { value: string | null; } | null; branched_from: { value: string | null; } | null; status: { value: "DELETING" | "MERGED" | "MERGE_FAILED" | "MERGING" | "NEED_REBASE" | "NEED_UPGRADE_REBASE" | "OPEN"; }; created_at: string | null; sync_with_git: { value: boolean | null; } | null; is_default: { value: boolean | null; } | null; has_schema_changes: { value: boolean | null; } | null; }; node_metadata: { created_at: unknown; created_by: { __typename: "CoreAccount"; id: string | null; display_label: string | null; hfid: string[] | null; } | null; updated_at: unknown; updated_by: { __typename: "CoreAccount"; id: string | null; display_label: string | null; hfid: string[] | null; } | null; }; }[]; }; }, { updatedAtBefore?: unknown; updatedAtAfter?: unknown; createdAtBefore?: unknown; createdAtAfter?: unknown; branchedFromBefore?: unknown; branchedFromAfter?: unknown; createdById?: string | null | undefined; statusValue?: "DELETING" | "MERGED" | "MERGE_FAILED" | "MERGING" | "NEED_REBASE" | "NEED_UPGRADE_REBASE" | "OPEN" | null | undefined; partialMatch?: boolean | null | undefined; nameValue?: string | null | undefined; offset?: number | null | undefined; limit?: number | null | undefined; }, void>; - /** @gql.tada/hash sha256:9190339d4b3488628a28fc2ad01d51a7 */ + /** @gql.tada/hash sha256:3bf6c59ad7574b7c51d6506ca7a4537a */ "\n mutation BRANCH_MERGE($name: String) {\n BranchMerge(wait_until_completion: false, data: { name: $name }) {\n ok\n task {\n id\n }\n }\n }\n": TadaDocumentNode<{ BranchMerge: { ok: boolean | null; task: { id: string | null; } | null; } | null; }, { name?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:ddb5c76afb83f46f222533ad490098f8 */ + /** @gql.tada/hash sha256:031071ccc0b72d4dea7b3c89e18330c9 */ "\n mutation BRANCH_REBASE($name: String, $waitUntilCompletion: Boolean!) {\n BranchRebase(wait_until_completion: $waitUntilCompletion, data: { name: $name }) {\n ok\n object {\n id\n name\n description\n origin_branch\n branched_from\n created_at\n status\n sync_with_git\n is_default\n has_schema_changes\n }\n task {\n id\n }\n }\n }\n": TadaDocumentNode<{ BranchRebase: { ok: boolean | null; object: { id: string; name: string; description: string | null; origin_branch: string | null; branched_from: string | null; created_at: string | null; status: "DELETING" | "MERGED" | "MERGE_FAILED" | "MERGING" | "NEED_REBASE" | "NEED_UPGRADE_REBASE" | "OPEN"; sync_with_git: boolean | null; is_default: boolean | null; has_schema_changes: boolean | null; } | null; task: { id: string | null; } | null; } | null; }, { waitUntilCompletion: boolean; name?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:403415460b0f7ee18b17d7b49a8c6a4f */ + /** @gql.tada/hash sha256:21a704d6a422f480ed6934b390e68e8a */ "\n mutation BRANCH_VALIDATE($name: String) {\n BranchValidate(wait_until_completion: false, data: { name: $name }) {\n ok\n task {\n id\n }\n }\n }\n": TadaDocumentNode<{ BranchValidate: { ok: boolean | null; task: { id: string | null; } | null; } | null; }, { name?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:1a7c0cec5e09ac457f1825ffee71cb21 */ + /** @gql.tada/hash sha256:33a6ebb314529b3bd1240c40a1f30aa4 */ "\n query GET_ARTIFACT_THREADS($changeIds: [ID!]) {\n CoreArtifactThread(change__ids: $changeIds) {\n count\n edges {\n node {\n id\n display_label\n __typename\n line_number {\n value\n }\n storage_id {\n value\n }\n resolved {\n value\n }\n comments {\n edges {\n node_metadata {\n created_at\n created_by {\n display_label\n }\n }\n node {\n id\n text {\n value\n }\n }\n }\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreArtifactThread: { count: number; edges: { node: { id: string; display_label: string | null; __typename: "CoreArtifactThread"; line_number: { value: unknown; } | null; storage_id: { value: string | null; } | null; resolved: { value: boolean | null; } | null; comments: { edges: { node_metadata: { created_at: unknown; created_by: { __typename?: "CoreAccount" | undefined; display_label: string | null; } | null; } | null; node: { id: string; text: { value: string | null; } | null; } | null; }[]; }; } | null; }[]; }; }, { changeIds?: string[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:02d834250fc0cdbf9a5ac41060465384 */ + /** @gql.tada/hash sha256:7c1cfe0b230dadc281edcaea7520c8c7 */ "\n query GET_CHECK_DETAILS($id: ID!) {\n CoreCheck(ids: [$id]) {\n edges {\n node {\n id\n display_label\n name {\n value\n }\n message {\n value\n }\n severity {\n value\n }\n conclusion {\n value\n }\n kind {\n value\n }\n origin {\n value\n }\n created_at {\n value\n }\n ... on CoreDataCheck {\n conflicts {\n value\n }\n keep_branch {\n value\n }\n }\n ... on CoreSchemaCheck {\n conflicts {\n value\n }\n }\n ... on CoreFileCheck {\n files {\n value\n }\n commit {\n value\n }\n }\n ... on CoreArtifactCheck {\n storage_id {\n value\n }\n artifact_id {\n value\n }\n }\n __typename\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreCheck: { edges: { node: { __typename: "CoreArtifactCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; storage_id: { value: string | null; } | null; artifact_id: { value: string | null; } | null; } | { __typename: "CoreDataCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; keep_branch: { value: string | null; } | null; } | { __typename: "CoreFileCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; files: { value: unknown; } | null; commit: { value: string | null; } | null; } | { __typename: "CoreGeneratorCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | { __typename: "CoreSchemaCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreStandardCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | null; }[]; }; }, { id: string; }, void>; - /** @gql.tada/hash sha256:c080adf2b3487b914801514bd6c946e7 */ + /** @gql.tada/hash sha256:18451e11a2f079fda43792dc178d6cc8 */ "\n query GET_OBJECT_THREAD_COMMENTS($changeIds: [ID!], $objectPath: String) {\n CoreObjectThread(change__ids: $changeIds, object_path__value: $objectPath) {\n count\n edges {\n node {\n __typename\n id\n display_label\n resolved {\n value\n }\n comments {\n count\n edges {\n node_metadata {\n created_at\n created_by {\n display_label\n }\n }\n node {\n id\n display_label\n text {\n value\n }\n }\n }\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreObjectThread: { count: number; edges: { node: { __typename: "CoreObjectThread"; id: string; display_label: string | null; resolved: { value: boolean | null; } | null; comments: { count: number; edges: { node_metadata: { created_at: unknown; created_by: { __typename?: "CoreAccount" | undefined; display_label: string | null; } | null; } | null; node: { id: string; display_label: string | null; text: { value: string | null; } | null; } | null; }[]; }; } | null; }[]; }; }, { objectPath?: string | null | undefined; changeIds?: string[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:7653ae0398ff65967db09adcee2e4694 */ + /** @gql.tada/hash sha256:da1f47a581bcb197597d010dcf74b67a */ "\n query GET_OBJECT_THREADS($changeIds: [ID!], $objectPath: String) {\n CoreObjectThread(change__ids: $changeIds, object_path__value: $objectPath) {\n count\n edges {\n node {\n __typename\n id\n comments {\n count\n }\n }\n }\n permissions {\n edges {\n node {\n kind\n view\n create\n update\n delete\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreObjectThread: { count: number; edges: { node: { __typename: "CoreObjectThread"; id: string; comments: { count: number; }; } | null; }[]; permissions: { edges: { node: { kind: string; view: "ALLOW" | "ALLOW_DEFAULT" | "ALLOW_OTHER" | "DENY"; create: "ALLOW" | "ALLOW_DEFAULT" | "ALLOW_OTHER" | "DENY"; update: "ALLOW" | "ALLOW_DEFAULT" | "ALLOW_OTHER" | "DENY"; delete: "ALLOW" | "ALLOW_DEFAULT" | "ALLOW_OTHER" | "DENY"; }; }[]; }; }; }, { objectPath?: string | null | undefined; changeIds?: string[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:1717ffaa5e5b73f659e8f4259027deb4 */ + /** @gql.tada/hash sha256:33b8a6398265390f0bdbf1da1b39ddd3 */ "\n query GET_DIFF_TREE($branchName: String, $filters: DiffTreeQueryFilters, $limit: Int, $offset: Int, $proposedChangeId: String) {\n DiffTree(branch: $branchName, filters: $filters, include_parents: true, limit: $limit, offset: $offset, proposed_change_id: $proposedChangeId) {\n nodes {\n uuid\n relationships {\n label\n status\n contains_conflict\n cardinality\n elements {\n conflict {\n base_branch_label\n base_branch_action\n base_branch_changed_at\n base_branch_value\n diff_branch_label\n diff_branch_action\n diff_branch_changed_at\n diff_branch_value\n selected_branch\n uuid\n }\n last_changed_at\n contains_conflict\n peer_id\n properties {\n conflict {\n base_branch_label\n base_branch_action\n base_branch_changed_at\n base_branch_value\n diff_branch_label\n diff_branch_action\n diff_branch_changed_at\n diff_branch_value\n selected_branch\n uuid\n }\n last_changed_at\n new_value\n previous_value\n property_type\n status\n path_identifier\n }\n status\n path_identifier\n peer_label\n }\n last_changed_at\n name\n path_identifier\n }\n conflict {\n base_branch_label\n base_branch_action\n base_branch_changed_at\n diff_branch_action\n diff_branch_label\n base_branch_value\n diff_branch_changed_at\n diff_branch_value\n selected_branch\n uuid\n }\n attributes {\n contains_conflict\n last_changed_at\n name\n conflict {\n base_branch_label\n base_branch_action\n base_branch_changed_at\n base_branch_value\n diff_branch_label\n diff_branch_action\n diff_branch_changed_at\n diff_branch_value\n selected_branch\n uuid\n }\n properties {\n conflict {\n base_branch_label\n base_branch_action\n base_branch_changed_at\n base_branch_value\n diff_branch_label\n diff_branch_action\n diff_branch_changed_at\n diff_branch_value\n selected_branch\n uuid\n }\n last_changed_at\n new_value\n previous_value\n property_type\n status\n path_identifier\n }\n status\n path_identifier\n }\n kind\n contains_conflict\n label\n last_changed_at\n status\n path_identifier\n parent {\n uuid\n relationship_name\n kind\n }\n }\n to_time\n base_branch\n diff_branch\n from_time\n }\n }\n": TadaDocumentNode<{ DiffTree: { nodes: { uuid: string; relationships: { label: string | null; status: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; contains_conflict: boolean; cardinality: "MANY" | "ONE"; elements: { conflict: { base_branch_label: string | null; base_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; base_branch_changed_at: unknown; base_branch_value: string | null; diff_branch_label: string | null; diff_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; diff_branch_changed_at: unknown; diff_branch_value: string | null; selected_branch: "BASE_BRANCH" | "DIFF_BRANCH" | null; uuid: string; } | null; last_changed_at: unknown; contains_conflict: boolean; peer_id: string; properties: { conflict: { base_branch_label: string | null; base_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; base_branch_changed_at: unknown; base_branch_value: string | null; diff_branch_label: string | null; diff_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; diff_branch_changed_at: unknown; diff_branch_value: string | null; selected_branch: "BASE_BRANCH" | "DIFF_BRANCH" | null; uuid: string; } | null; last_changed_at: unknown; new_value: string | null; previous_value: string | null; property_type: string; status: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; path_identifier: string; }[] | null; status: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; path_identifier: string; peer_label: string | null; }[]; last_changed_at: unknown; name: string; path_identifier: string; }[]; conflict: { base_branch_label: string | null; base_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; base_branch_changed_at: unknown; diff_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; diff_branch_label: string | null; base_branch_value: string | null; diff_branch_changed_at: unknown; diff_branch_value: string | null; selected_branch: "BASE_BRANCH" | "DIFF_BRANCH" | null; uuid: string; } | null; attributes: { contains_conflict: boolean; last_changed_at: unknown; name: string; conflict: { base_branch_label: string | null; base_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; base_branch_changed_at: unknown; base_branch_value: string | null; diff_branch_label: string | null; diff_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; diff_branch_changed_at: unknown; diff_branch_value: string | null; selected_branch: "BASE_BRANCH" | "DIFF_BRANCH" | null; uuid: string; } | null; properties: { conflict: { base_branch_label: string | null; base_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; base_branch_changed_at: unknown; base_branch_value: string | null; diff_branch_label: string | null; diff_branch_action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; diff_branch_changed_at: unknown; diff_branch_value: string | null; selected_branch: "BASE_BRANCH" | "DIFF_BRANCH" | null; uuid: string; } | null; last_changed_at: unknown; new_value: string | null; previous_value: string | null; property_type: string; status: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; path_identifier: string; }[] | null; status: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; path_identifier: string; }[]; kind: string; contains_conflict: boolean; label: string; last_changed_at: unknown; status: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; path_identifier: string; parent: { uuid: string; relationship_name: string | null; kind: string | null; } | null; }[] | null; to_time: unknown; base_branch: string; diff_branch: string; from_time: unknown; } | null; }, { proposedChangeId?: string | null | undefined; offset?: number | null | undefined; limit?: number | null | undefined; filters?: { status?: { includes?: ("ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED" | null)[] | null | undefined; excludes?: ("ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED" | null)[] | null | undefined; } | null | undefined; namespace?: { includes?: (string | null)[] | null | undefined; excludes?: (string | null)[] | null | undefined; } | null | undefined; kind?: { includes?: (string | null)[] | null | undefined; excludes?: (string | null)[] | null | undefined; } | null | undefined; ids?: (string | null)[] | null | undefined; } | null | undefined; branchName?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:cc12b829e276df170c55e69749e7a7ce */ + /** @gql.tada/hash sha256:ef28f14ab41d73e80d90cefb8a677b8e */ "\n query GET_DIFF_TREE_SUMMARY($branch: String, $filters: DiffTreeQueryFilters, $proposedChangeId: String) {\n DiffTreeSummary(branch: $branch, filters: $filters, proposed_change_id: $proposedChangeId) {\n num_added\n num_updated\n num_removed\n num_conflicts\n }\n }\n": TadaDocumentNode<{ DiffTreeSummary: { num_added: number; num_updated: number; num_removed: number; num_conflicts: number; } | null; }, { proposedChangeId?: string | null | undefined; filters?: { status?: { includes?: ("ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED" | null)[] | null | undefined; excludes?: ("ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED" | null)[] | null | undefined; } | null | undefined; namespace?: { includes?: (string | null)[] | null | undefined; excludes?: (string | null)[] | null | undefined; } | null | undefined; kind?: { includes?: (string | null)[] | null | undefined; excludes?: (string | null)[] | null | undefined; } | null | undefined; ids?: (string | null)[] | null | undefined; } | null | undefined; branch?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:902068910544cf8ac3c482eca6348853 */ + /** @gql.tada/hash sha256:6c62e3f8d4cfdff3baf194f3dbe4f278 */ "\n query GET_FILE_THREADS($changeIds: [ID!]) {\n CoreFileThread(change__ids: $changeIds) {\n count\n edges {\n node {\n id\n display_label\n resolved {\n value\n }\n __typename\n file {\n value\n }\n commit {\n value\n }\n repository {\n node {\n id\n }\n }\n line_number {\n value\n }\n comments {\n edges {\n node_metadata {\n created_at\n created_by {\n display_label\n }\n }\n node {\n id\n text {\n value\n }\n }\n }\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreFileThread: { count: number; edges: { node: { id: string; display_label: string | null; resolved: { value: boolean | null; } | null; __typename: "CoreFileThread"; file: { value: string | null; } | null; commit: { value: string | null; } | null; repository: { node: { id: string; } | null; }; line_number: { value: unknown; } | null; comments: { edges: { node_metadata: { created_at: unknown; created_by: { __typename?: "CoreAccount" | undefined; display_label: string | null; } | null; } | null; node: { id: string; text: { value: string | null; } | null; } | null; }[]; }; } | null; }[]; }; }, { changeIds?: string[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:f96f047ff5111e7dbfda711382b37841 */ + /** @gql.tada/hash sha256:d389b807e031bc585807e6cc8f2b06e9 */ "\n query GET_VALIDATOR_DETAILS($ids: [ID!], $checksOffset: Int, $checksLimit: Int) {\n CoreValidator(ids: $ids) {\n edges {\n node {\n id\n display_label\n conclusion {\n value\n }\n started_at {\n value\n }\n completed_at {\n value\n }\n state {\n value\n }\n ... on CoreRepositoryValidator {\n repository {\n node {\n display_label\n }\n }\n }\n ... on CoreArtifactValidator {\n definition {\n node {\n display_label\n name {\n value\n }\n description {\n value\n }\n }\n }\n }\n checks(offset: $checksOffset, limit: $checksLimit) {\n count\n edges {\n node {\n id\n display_label\n name {\n value\n }\n message {\n value\n }\n severity {\n value\n }\n conclusion {\n value\n }\n kind {\n value\n }\n origin {\n value\n }\n created_at {\n value\n }\n ... on CoreDataCheck {\n conflicts {\n value\n }\n }\n ... on CoreSchemaCheck {\n conflicts {\n value\n }\n }\n ... on CoreFileCheck {\n files {\n value\n }\n commit {\n value\n }\n }\n ... on CoreArtifactCheck {\n storage_id {\n value\n }\n artifact_id {\n value\n }\n }\n __typename\n }\n }\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreValidator: { edges: { node: { __typename?: "CoreArtifactValidator" | undefined; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; definition: { node: { display_label: string | null; name: { value: string | null; } | null; description: { value: string | null; } | null; } | null; }; checks: { count: number; edges: { node: { __typename: "CoreArtifactCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; storage_id: { value: string | null; } | null; artifact_id: { value: string | null; } | null; } | { __typename: "CoreDataCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreFileCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; files: { value: unknown; } | null; commit: { value: string | null; } | null; } | { __typename: "CoreGeneratorCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | { __typename: "CoreSchemaCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreStandardCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | null; }[] | null; }; } | { __typename?: "CoreDataValidator" | undefined; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { count: number; edges: { node: { __typename: "CoreArtifactCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; storage_id: { value: string | null; } | null; artifact_id: { value: string | null; } | null; } | { __typename: "CoreDataCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreFileCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; files: { value: unknown; } | null; commit: { value: string | null; } | null; } | { __typename: "CoreGeneratorCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | { __typename: "CoreSchemaCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreStandardCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | null; }[] | null; }; } | { __typename?: "CoreGeneratorValidator" | undefined; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { count: number; edges: { node: { __typename: "CoreArtifactCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; storage_id: { value: string | null; } | null; artifact_id: { value: string | null; } | null; } | { __typename: "CoreDataCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreFileCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; files: { value: unknown; } | null; commit: { value: string | null; } | null; } | { __typename: "CoreGeneratorCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | { __typename: "CoreSchemaCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreStandardCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | null; }[] | null; }; } | { __typename?: "CoreRepositoryValidator" | undefined; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; repository: { node: { __typename?: "CoreReadOnlyRepository" | undefined; display_label: string | null; } | { __typename?: "CoreRepository" | undefined; display_label: string | null; } | null; }; checks: { count: number; edges: { node: { __typename: "CoreArtifactCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; storage_id: { value: string | null; } | null; artifact_id: { value: string | null; } | null; } | { __typename: "CoreDataCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreFileCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; files: { value: unknown; } | null; commit: { value: string | null; } | null; } | { __typename: "CoreGeneratorCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | { __typename: "CoreSchemaCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreStandardCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | null; }[] | null; }; } | { __typename?: "CoreSchemaValidator" | undefined; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { count: number; edges: { node: { __typename: "CoreArtifactCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; storage_id: { value: string | null; } | null; artifact_id: { value: string | null; } | null; } | { __typename: "CoreDataCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreFileCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; files: { value: unknown; } | null; commit: { value: string | null; } | null; } | { __typename: "CoreGeneratorCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | { __typename: "CoreSchemaCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreStandardCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | null; }[] | null; }; } | { __typename?: "CoreUserValidator" | undefined; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { count: number; edges: { node: { __typename: "CoreArtifactCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; storage_id: { value: string | null; } | null; artifact_id: { value: string | null; } | null; } | { __typename: "CoreDataCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreFileCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; files: { value: unknown; } | null; commit: { value: string | null; } | null; } | { __typename: "CoreGeneratorCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | { __typename: "CoreSchemaCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; conflicts: { value: unknown; } | null; } | { __typename: "CoreStandardCheck"; id: string | null; display_label: string | null; name: { value: string | null; } | null; message: { value: string | null; } | null; severity: { value: string | null; } | null; conclusion: { value: string | null; } | null; kind: { value: string | null; } | null; origin: { value: string | null; } | null; created_at: { value: string | null; } | null; } | null; }[] | null; }; } | null; }[]; }; }, { checksLimit?: number | null | undefined; checksOffset?: number | null | undefined; ids?: string[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:386da0e74783828056dc26c5d8e45175 */ + /** @gql.tada/hash sha256:e5a95985d66e66b26c4d68b8a1b7db91 */ "\n query GET_CORE_VALIDATORS($id: ID!) {\n CoreValidator(proposed_change__ids: [$id]) {\n edges {\n node {\n id\n display_label\n conclusion {\n value\n }\n started_at {\n value\n }\n completed_at {\n value\n }\n state {\n value\n }\n checks {\n edges {\n node {\n conclusion {\n value\n }\n severity {\n value\n }\n }\n }\n }\n ... on CoreArtifactValidator {\n definition {\n node {\n id\n display_label\n __typename\n }\n }\n }\n __typename\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreValidator: { edges: { node: { __typename: "CoreArtifactValidator"; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { edges: { node: { __typename?: "CoreArtifactCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreDataCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreFileCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreGeneratorCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreSchemaCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreStandardCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | null; }[] | null; }; definition: { node: { id: string; display_label: string | null; __typename: "CoreArtifactDefinition"; } | null; }; } | { __typename: "CoreDataValidator"; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { edges: { node: { __typename?: "CoreArtifactCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreDataCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreFileCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreGeneratorCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreSchemaCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreStandardCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | null; }[] | null; }; } | { __typename: "CoreGeneratorValidator"; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { edges: { node: { __typename?: "CoreArtifactCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreDataCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreFileCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreGeneratorCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreSchemaCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreStandardCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | null; }[] | null; }; } | { __typename: "CoreRepositoryValidator"; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { edges: { node: { __typename?: "CoreArtifactCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreDataCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreFileCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreGeneratorCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreSchemaCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreStandardCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | null; }[] | null; }; } | { __typename: "CoreSchemaValidator"; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { edges: { node: { __typename?: "CoreArtifactCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreDataCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreFileCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreGeneratorCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreSchemaCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreStandardCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | null; }[] | null; }; } | { __typename: "CoreUserValidator"; id: string | null; display_label: string | null; conclusion: { value: string | null; } | null; started_at: { value: string | null; } | null; completed_at: { value: string | null; } | null; state: { value: string | null; } | null; checks: { edges: { node: { __typename?: "CoreArtifactCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreDataCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreFileCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreGeneratorCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreSchemaCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | { __typename?: "CoreStandardCheck" | undefined; conclusion: { value: string | null; } | null; severity: { value: string | null; } | null; } | null; }[] | null; }; } | null; }[]; }; }, { id: string; }, void>; - /** @gql.tada/hash sha256:fdafb6ef86381bb0c7f1dd329656009b */ + /** @gql.tada/hash sha256:7610f1f629cb0e3c7a955a4ce08ac712 */ "\n mutation RESOLVE_CONFLICT($id: String, $selection: ConflictSelection) {\n ResolveDiffConflict(data: { conflict_id: $id, selected_branch: $selection }) {\n ok\n }\n }\n": TadaDocumentNode<{ ResolveDiffConflict: { ok: boolean | null; } | null; }, { selection?: "BASE_BRANCH" | "DIFF_BRANCH" | null | undefined; id?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:864e8db2b4a698880840769e5a052d4e */ + /** @gql.tada/hash sha256:3ac40a857b94f9ee8efe48b881111132 */ "\n mutation RUN_CHECK($proposedChangeId: String!, $checkType: CheckType) {\n CoreProposedChangeRunCheck(data: { id: $proposedChangeId, check_type: $checkType }) {\n ok\n }\n }\n": TadaDocumentNode<{ CoreProposedChangeRunCheck: { ok: boolean | null; } | null; }, { checkType?: "ALL" | "ARTIFACT" | "DATA" | "GENERATOR" | "REPOSITORY" | "SCHEMA" | "TEST" | "USER" | null | undefined; proposedChangeId: string; }, void>; - /** @gql.tada/hash sha256:1595e83bbe8c3bbf3ea477ad75c3d792 */ + /** @gql.tada/hash sha256:db0c714f77dbfcca5babe803cdc4f611 */ "\n mutation DIFF_UPDATE($branchName: String!, $waitUntilCompletion: Boolean) {\n DiffUpdate(data: { branch: $branchName }, wait_until_completion: $waitUntilCompletion) {\n ok\n }\n }\n": TadaDocumentNode<{ DiffUpdate: { ok: boolean | null; } | null; }, { waitUntilCompletion?: boolean | null | undefined; branchName: string; }, void>; - /** @gql.tada/hash sha256:d5b712a60c1b3ec6d4fa61a8521a3d21 */ + /** @gql.tada/hash sha256:8da0f1a12ca2b6cb15d594d6fe1a42e8 */ "\n query GET_INFRAHUB_EVENTS(\n $ids: [String!]\n $hasChildren: Boolean\n $branches: [String!]\n $eventType: [String!]\n $primaryNodeIds: [String!]\n $relatedNodeIds: [String!]\n $parentIds: [String!]\n $accountIds: [String!]\n $level: Int\n $since: DateTime\n $until: DateTime\n $offset: Int\n $limit: Int\n $order: EventSortOrder\n ) {\n InfrahubEvent(\n ids: $ids\n has_children: $hasChildren\n branches: $branches\n event_type: $eventType\n primary_node__ids: $primaryNodeIds\n related_node__ids: $relatedNodeIds\n parent__ids: $parentIds\n account__ids: $accountIds\n level: $level\n since: $since\n until: $until\n offset: $offset\n limit: $limit\n order: $order\n ) {\n edges {\n node {\n id\n event\n branch\n occurred_at\n level\n account_id\n primary_node {\n id\n kind\n }\n related_nodes {\n id\n kind\n }\n has_children\n __typename\n ... on NodeMutatedEvent {\n attributes {\n action\n kind\n name\n value\n value_previous\n }\n relationships {\n action\n name\n peer {\n id\n kind\n }\n }\n payload\n }\n ... on StandardEvent {\n payload\n }\n ... on BranchCreatedEvent {\n payload\n created_branch\n }\n ... on BranchDeletedEvent {\n payload\n deleted_branch\n }\n ... on BranchRebasedEvent {\n payload\n rebased_branch\n }\n ... on BranchMergedEvent {\n source_branch\n }\n ... on GroupEvent {\n ancestors {\n id\n kind\n }\n members {\n id\n kind\n }\n }\n ... on ArtifactEvent {\n checksum\n storage_id\n artifact_definition_id\n checksum_previous\n storage_id_previous\n }\n ... on AccountLoggedInEventType {\n account_name\n account_type\n auth_method\n session_id\n timestamp\n client_ip\n user_agent\n groups\n roles\n identity_source\n }\n ... on AccountLoggedOutEventType {\n account_name\n logout_type\n session_id\n timestamp\n client_ip\n user_agent\n }\n ... on GroupAutoCreatedEventType {\n idp\n protocol\n triggering_user_id\n triggering_user_name\n group_id\n group_name\n source_pattern\n origin_value\n }\n ... on GroupAutoCreateRejectedEventType {\n idp\n protocol\n triggering_user_id\n triggering_user_name\n rejected_claim_value\n }\n ... on GroupAutoCreateCappedEventType {\n idp\n protocol\n triggering_user_id\n triggering_user_name\n cap_value\n dropped_count\n dropped_claims\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubEvent: { edges: { node: { __typename: "AccountLoggedInEventType"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; account_name: string; account_type: string; auth_method: string; session_id: string; timestamp: unknown; client_ip: string | null; user_agent: string | null; groups: string[]; roles: string[]; identity_source: string | null; } | { __typename: "AccountLoggedOutEventType"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; account_name: string; logout_type: string; session_id: string; timestamp: unknown; client_ip: string | null; user_agent: string | null; } | { __typename: "ArtifactEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; checksum: string; storage_id: string; artifact_definition_id: string; checksum_previous: string | null; storage_id_previous: string | null; } | { __typename: "BranchCreatedEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; payload: unknown; created_branch: string; } | { __typename: "BranchDeletedEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; payload: unknown; deleted_branch: string; } | { __typename: "BranchMergedEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; source_branch: string; } | { __typename: "BranchRebasedEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; payload: unknown; rebased_branch: string; } | { __typename: "GroupAutoCreateCappedEventType"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; idp: string; protocol: string; triggering_user_id: string; triggering_user_name: string; cap_value: number; dropped_count: number; dropped_claims: string[]; } | { __typename: "GroupAutoCreateRejectedEventType"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; idp: string; protocol: string; triggering_user_id: string; triggering_user_name: string; rejected_claim_value: string; } | { __typename: "GroupAutoCreatedEventType"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; idp: string; protocol: string; triggering_user_id: string; triggering_user_name: string; group_id: string; group_name: string; source_pattern: string; origin_value: string; } | { __typename: "GroupEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; ancestors: { id: string; kind: string; }[]; members: { id: string; kind: string; }[]; } | { __typename: "NodeMutatedEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; attributes: { action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; kind: string; name: string; value: string | null; value_previous: string | null; }[]; relationships: { action: "ADDED" | "REMOVED" | "UNCHANGED" | "UPDATED"; name: string; peer: { id: string; kind: string; }; }[]; payload: unknown; } | { __typename: "ProposedChangeApprovalsRevokedEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; } | { __typename: "ProposedChangeMergedEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; } | { __typename: "ProposedChangeReviewEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; } | { __typename: "ProposedChangeReviewRequestedEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; } | { __typename: "ProposedChangeReviewRevokedEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; } | { __typename: "ProposedChangeThreadEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; } | { __typename: "StandardEvent"; id: string; event: string; branch: string | null; occurred_at: unknown; level: number; account_id: string | null; primary_node: { id: string; kind: string; } | null; related_nodes: { id: string; kind: string; }[]; has_children: boolean; payload: unknown; } | null; }[]; }; }, { order?: "ASC" | "DESC" | null | undefined; limit?: number | null | undefined; offset?: number | null | undefined; until?: unknown; since?: unknown; level?: number | null | undefined; accountIds?: string[] | null | undefined; parentIds?: string[] | null | undefined; relatedNodeIds?: string[] | null | undefined; primaryNodeIds?: string[] | null | undefined; eventType?: string[] | null | undefined; branches?: string[] | null | undefined; hasChildren?: boolean | null | undefined; ids?: string[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:5019dcdb2c4779bca73d4392dcc7e21c */ + /** @gql.tada/hash sha256:ffecab70c64680ca89fe5f25396cb0c0 */ "\n mutation CoreGeneratorDefinitionRun($generatorId: String!, $waitUntilCompletion: Boolean, $targetNodeIds: [String!]) {\n CoreGeneratorDefinitionRun(\n wait_until_completion: $waitUntilCompletion\n data: { id: $generatorId, nodes: $targetNodeIds }\n ) {\n task {\n id\n }\n }\n }\n": TadaDocumentNode<{ CoreGeneratorDefinitionRun: { task: { id: string | null; } | null; } | null; }, { targetNodeIds?: string[] | null | undefined; waitUntilCompletion?: boolean | null | undefined; generatorId: string; }, void>; - /** @gql.tada/hash sha256:71e28047d3fd3fa5ece772b10603fe9f */ + /** @gql.tada/hash sha256:ca95cf9f3c855b134a6fa00d17c7bcd8 */ "\n query getNextIPAddressAvailable($parentPrefixId: String!) {\n InfrahubIPAddressGetNextAvailable(prefix_id: $parentPrefixId) {\n address\n }\n }\n": TadaDocumentNode<{ InfrahubIPAddressGetNextAvailable: { address: string; }; }, { parentPrefixId: string; }, void>; - /** @gql.tada/hash sha256:86dae3436276b832a565e3530e7c8a59 */ + /** @gql.tada/hash sha256:3e1b64ad8b9a0059dd5a6ddcb0c31411 */ "\n query getNextIPPrefixAvailable($parentPrefixId: String!) {\n InfrahubIPPrefixGetNextAvailable(prefix_id: $parentPrefixId) {\n prefix\n }\n }\n": TadaDocumentNode<{ InfrahubIPPrefixGetNextAvailable: { prefix: string; }; }, { parentPrefixId: string; }, void>; - /** @gql.tada/hash sha256:b8a6acd6071b7f4626853517597164d9 */ + /** @gql.tada/hash sha256:26ec8fff6f08a34edc3ae1b4c52e3add */ "\n query GET_IPAM_TREE_NODES(\n $isTopLevel: Boolean\n $parentIds: [ID!]\n $search: String\n $ipNamespaceIds: [ID!]\n $limit: Int\n $offset: Int\n ) {\n BuiltinIPPrefix(\n is_top_level__value: $isTopLevel\n parent__ids: $parentIds\n any__value: $search\n partial_match: true\n ip_namespace__ids: $ipNamespaceIds\n offset: $offset\n limit: $limit\n ) {\n edges {\n node {\n id\n display_label\n descendants {\n count\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ BuiltinIPPrefix: { edges: { node: { __typename?: "InternalIPPrefixAvailable" | undefined; id: string | null; display_label: string | null; descendants: { count: number; }; } | null; }[]; }; }, { offset?: number | null | undefined; limit?: number | null | undefined; ipNamespaceIds?: string[] | null | undefined; search?: string | null | undefined; parentIds?: string[] | null | undefined; isTopLevel?: boolean | null | undefined; }, void>; - /** @gql.tada/hash sha256:a553facc93e909db54249f408783a55a */ + /** @gql.tada/hash sha256:a952fb9a9f599f592b09575679887045 */ "\n query Search($search: String!, $caseSensitive: Boolean) {\n InfrahubSearchAnywhere(q: $search, limit: 4, partial_match: true, case_sensitive: $caseSensitive) {\n count\n edges {\n node {\n id\n kind\n }\n }\n parent_prefixes {\n node {\n id\n kind\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubSearchAnywhere: { count: number; edges: { node: { id: string; kind: string; }; }[]; parent_prefixes: { node: { id: string; kind: string; }; }[] | null; }; }, { caseSensitive?: boolean | null | undefined; search: string; }, void>; - /** @gql.tada/hash sha256:f8b33c42350bc7cee77ef2f1c7e8fcbe */ + /** @gql.tada/hash sha256:ecc00afd13ac996c05bbafee0dab9a6c */ "\n mutation CONVERT_OBJECT_MUTATION($nodeId: String!, $targetKind: String!, $fieldsMapping: GenericScalar!) {\n ConvertObjectType(\n data: { node_id: $nodeId, target_kind: $targetKind, fields_mapping: $fieldsMapping }\n ) {\n node\n }\n }\n": TadaDocumentNode<{ ConvertObjectType: { node: unknown; } | null; }, { fieldsMapping: unknown; targetKind: string; nodeId: string; }, void>; - /** @gql.tada/hash sha256:bd382d3f0f66ced1dfea0a2985684da6 */ + /** @gql.tada/hash sha256:0776d80973dade8a4c8fc2fa5f2ace6d */ "\n query GET_FIELDS_MAPPING($sourceKind: String!, $targetKind: String!) {\n FieldsMappingTypeConversion(source_kind: $sourceKind, target_kind: $targetKind) {\n mapping\n }\n }\n": TadaDocumentNode<{ FieldsMappingTypeConversion: { mapping: unknown; }; }, { targetKind: string; sourceKind: string; }, void>; - /** @gql.tada/hash sha256:7f460ef0a53576a477f652e363742a31 */ + /** @gql.tada/hash sha256:f8525b8c3b94415a45052c43d63ec973 */ "\n mutation RelationshipAdd(\n $objectId: String!\n $relationshipName: String!\n $relationshipIds: [RelatedNodeInput]\n ) {\n RelationshipAdd(data: { id: $objectId, name: $relationshipName, nodes: $relationshipIds }) {\n ok\n }\n }\n": TadaDocumentNode<{ RelationshipAdd: { ok: boolean | null; } | null; }, { relationshipIds?: ({ kind?: string | null | undefined; id?: string | null | undefined; hfid?: (string | null)[] | null | undefined; from_pool?: { identifier?: string | null | undefined; id: string; data?: unknown; } | null | undefined; _relation__source?: string | null | undefined; _relation__owner?: string | null | undefined; _relation__is_protected?: boolean | null | undefined; } | null)[] | null | undefined; relationshipName: string; objectId: string; }, void>; - /** @gql.tada/hash sha256:f0a68116f6b3f78ad51a943f74e660e7 */ + /** @gql.tada/hash sha256:a34fc69963723135285b7f2799e05531 */ "\n mutation RelationshipRemove(\n $objectId: String!\n $relationshipName: String!\n $relationshipIds: [RelatedNodeInput]\n ) {\n RelationshipRemove(data: { id: $objectId, name: $relationshipName, nodes: $relationshipIds }) {\n ok\n }\n }\n": TadaDocumentNode<{ RelationshipRemove: { ok: boolean | null; } | null; }, { relationshipIds?: ({ kind?: string | null | undefined; id?: string | null | undefined; hfid?: (string | null)[] | null | undefined; from_pool?: { identifier?: string | null | undefined; id: string; data?: unknown; } | null | undefined; _relation__source?: string | null | undefined; _relation__owner?: string | null | undefined; _relation__is_protected?: boolean | null | undefined; } | null)[] | null | undefined; relationshipName: string; objectId: string; }, void>; - /** @gql.tada/hash sha256:0b3bba543485d453046bec107625a5fd */ + /** @gql.tada/hash sha256:1d717df5d131566bb2457c83e0b21ab0 */ "\n query InfrahubGlobalPermissions {\n InfrahubPermissions {\n global_permissions {\n edges {\n node {\n action\n decision\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubPermissions: { global_permissions: { edges: { node: { action: string; decision: string; }; }[]; } | null; }; }, {}, void>; - /** @gql.tada/hash sha256:1d5acd4ed75181470ceb3ef7e0b7443b */ + /** @gql.tada/hash sha256:5ca954bbd43213725a7dc186eaf10224 */ "\n query InfrahubEffectivePreferences {\n InfrahubEffectivePreferences {\n date_format {\n value\n source\n }\n timezone {\n value\n source\n }\n }\n }\n": - TadaDocumentNode<{ InfrahubEffectivePreferences: { date_format: { value: "EU_DATETIME" | "ISO_8601" | "ISO_DATETIME" | "ISO_DATETIME_SECONDS" | "US_12H" | null; source: "DEFAULT" | "GLOBAL" | "USER"; }; timezone: { value: string | null; source: "DEFAULT" | "GLOBAL" | "USER"; }; }; }, {}, void>; - /** @gql.tada/hash sha256:edf4287171621261eef831ea1441c436 */ + TadaDocumentNode<{ InfrahubEffectivePreferences: { date_format: { value: "EU_DATETIME" | "ISO_8601" | "ISO_DATETIME" | "ISO_DATETIME_SECONDS" | "US_12H" | null; source: "USER" | "DEFAULT" | "GLOBAL"; }; timezone: { value: string | null; source: "USER" | "DEFAULT" | "GLOBAL"; }; }; }, {}, void>; + /** @gql.tada/hash sha256:a237265ec2414a2290156056982f242c */ "\n query InfrahubGlobalPreferences {\n InfrahubGlobalPreferences {\n date_format\n timezone\n }\n }\n": TadaDocumentNode<{ InfrahubGlobalPreferences: { date_format: "EU_DATETIME" | "ISO_8601" | "ISO_DATETIME" | "ISO_DATETIME_SECONDS" | "US_12H" | null; timezone: string | null; }; }, {}, void>; - /** @gql.tada/hash sha256:ca14ef01cc570afa5433b3e016eef4b3 */ + /** @gql.tada/hash sha256:c41d40d323b5c036211c168de8ef908d */ "\n mutation UpdateGlobalPreference($dateFormat: DateFormat, $timezone: String) {\n InfrahubSetPreferences(scope: GLOBAL, date_format: $dateFormat, timezone: $timezone) {\n ok\n date_format\n timezone\n }\n }\n": TadaDocumentNode<{ InfrahubSetPreferences: { ok: boolean | null; date_format: "EU_DATETIME" | "ISO_8601" | "ISO_DATETIME" | "ISO_DATETIME_SECONDS" | "US_12H" | null; timezone: string | null; } | null; }, { timezone?: string | null | undefined; dateFormat?: "EU_DATETIME" | "ISO_8601" | "ISO_DATETIME" | "ISO_DATETIME_SECONDS" | "US_12H" | null | undefined; }, void>; - /** @gql.tada/hash sha256:8d9e32c82fc6667a421bd1c5801a1d12 */ + /** @gql.tada/hash sha256:c6fa11a01af80027c46eca662bd67d9e */ "\n mutation UpsertUserPreference($dateFormat: DateFormat, $timezone: String) {\n InfrahubSetPreferences(scope: USER, date_format: $dateFormat, timezone: $timezone) {\n ok\n date_format\n timezone\n }\n }\n": TadaDocumentNode<{ InfrahubSetPreferences: { ok: boolean | null; date_format: "EU_DATETIME" | "ISO_8601" | "ISO_DATETIME" | "ISO_DATETIME_SECONDS" | "US_12H" | null; timezone: string | null; } | null; }, { timezone?: string | null | undefined; dateFormat?: "EU_DATETIME" | "ISO_8601" | "ISO_DATETIME" | "ISO_DATETIME_SECONDS" | "US_12H" | null | undefined; }, void>; - /** @gql.tada/hash sha256:7f7bf8aadeeee4d139c8eb81a004793f */ + /** @gql.tada/hash sha256:f852cef35f0c467c6c9621b187803e4d */ "\n mutation CoreProposedChangeCreate(\n $name: String!\n $isDraft: Boolean\n $description: String\n $source_branch: String!\n $destination_branch: String!\n $reviewers: [RelatedNodeInput!]\n ) {\n CoreProposedChangeCreate(\n data: {\n name: { value: $name }\n is_draft: { value: $isDraft }\n description: { value: $description }\n source_branch: { value: $source_branch }\n destination_branch: { value: $destination_branch }\n reviewers: $reviewers\n }\n ) {\n object {\n id\n display_label\n }\n ok\n }\n }\n": TadaDocumentNode<{ CoreProposedChangeCreate: { object: { id: string; display_label: string | null; } | null; ok: boolean | null; } | null; }, { reviewers?: { kind?: string | null | undefined; id?: string | null | undefined; hfid?: (string | null)[] | null | undefined; from_pool?: { identifier?: string | null | undefined; id: string; data?: unknown; } | null | undefined; _relation__source?: string | null | undefined; _relation__owner?: string | null | undefined; _relation__is_protected?: boolean | null | undefined; }[] | null | undefined; destination_branch: string; source_branch: string; description?: string | null | undefined; isDraft?: boolean | null | undefined; name: string; }, void>; - /** @gql.tada/hash sha256:485b74e9a3e4c127bc0dd5f376bd57b8 */ + /** @gql.tada/hash sha256:0e4d83df22ec13ae902b09c5ae316403 */ "\n query GET_PROPOSED_CHANGE_DETAILS($proposedChangeId: ID) {\n CoreProposedChange(ids: [$proposedChangeId]) {\n count\n edges {\n node_metadata {\n created_at\n created_by {\n id\n hfid\n display_label\n __typename\n }\n updated_at\n updated_by {\n id\n hfid\n display_label\n __typename\n }\n }\n node {\n id\n display_label\n __typename\n name {\n value\n }\n description {\n value\n updated_at\n }\n source_branch {\n value\n }\n destination_branch {\n value\n }\n state {\n value\n }\n is_draft {\n value\n }\n approved_by {\n edges {\n node {\n id\n display_label\n }\n }\n }\n rejected_by {\n edges {\n node {\n id\n display_label\n }\n }\n }\n reviewers {\n edges {\n node {\n id\n display_label\n }\n }\n }\n comments {\n count\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreProposedChange: { count: number; edges: { node_metadata: { created_at: unknown; created_by: { __typename: "CoreAccount"; id: string | null; hfid: string[] | null; display_label: string | null; } | null; updated_at: unknown; updated_by: { __typename: "CoreAccount"; id: string | null; hfid: string[] | null; display_label: string | null; } | null; } | null; node: { id: string; display_label: string | null; __typename: "CoreProposedChange"; name: { value: string | null; } | null; description: { value: string | null; updated_at: unknown; } | null; source_branch: { value: string | null; } | null; destination_branch: { value: string | null; } | null; state: { value: string | null; } | null; is_draft: { value: boolean | null; } | null; approved_by: { edges: { node: { __typename?: "CoreAccount" | undefined; id: string | null; display_label: string | null; } | null; }[] | null; }; rejected_by: { edges: { node: { __typename?: "CoreAccount" | undefined; id: string | null; display_label: string | null; } | null; }[] | null; }; reviewers: { edges: { node: { __typename?: "CoreAccount" | undefined; id: string | null; display_label: string | null; } | null; }[] | null; }; comments: { count: number; }; } | null; }[]; }; }, { proposedChangeId?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:93f2ad535478e7a0c1754d7fb6632530 */ + /** @gql.tada/hash sha256:8ba5cb5ce48d31287647e3a501efb64a */ "\n query GetCoreThread($ids: [ID]) {\n CoreThread(ids: $ids) {\n edges {\n node {\n id\n display_label\n label {\n value\n }\n resolved {\n value\n }\n comments {\n count\n edges {\n node_metadata {\n created_at\n created_by {\n display_label\n }\n }\n node {\n id\n display_label\n text {\n value\n }\n }\n }\n }\n ... on CoreArtifactThread {\n storage_id {\n value\n }\n artifact_id {\n value\n }\n line_number {\n value\n }\n }\n ... on CoreObjectThread {\n object_path {\n value\n }\n }\n ... on CoreFileThread {\n file {\n value\n }\n line_number {\n value\n }\n commit {\n value\n }\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreThread: { edges: { node: { __typename?: "CoreArtifactThread" | undefined; id: string | null; display_label: string | null; label: { value: string | null; } | null; resolved: { value: boolean | null; } | null; comments: { count: number; edges: { node_metadata: { created_at: unknown; created_by: { __typename?: "CoreAccount" | undefined; display_label: string | null; } | null; } | null; node: { id: string; display_label: string | null; text: { value: string | null; } | null; } | null; }[]; }; storage_id: { value: string | null; } | null; artifact_id: { value: string | null; } | null; line_number: { value: unknown; } | null; } | { __typename?: "CoreChangeThread" | undefined; id: string | null; display_label: string | null; label: { value: string | null; } | null; resolved: { value: boolean | null; } | null; comments: { count: number; edges: { node_metadata: { created_at: unknown; created_by: { __typename?: "CoreAccount" | undefined; display_label: string | null; } | null; } | null; node: { id: string; display_label: string | null; text: { value: string | null; } | null; } | null; }[]; }; } | { __typename?: "CoreFileThread" | undefined; id: string | null; display_label: string | null; label: { value: string | null; } | null; resolved: { value: boolean | null; } | null; comments: { count: number; edges: { node_metadata: { created_at: unknown; created_by: { __typename?: "CoreAccount" | undefined; display_label: string | null; } | null; } | null; node: { id: string; display_label: string | null; text: { value: string | null; } | null; } | null; }[]; }; file: { value: string | null; } | null; line_number: { value: unknown; } | null; commit: { value: string | null; } | null; } | { __typename?: "CoreObjectThread" | undefined; id: string | null; display_label: string | null; label: { value: string | null; } | null; resolved: { value: boolean | null; } | null; comments: { count: number; edges: { node_metadata: { created_at: unknown; created_by: { __typename?: "CoreAccount" | undefined; display_label: string | null; } | null; } | null; node: { id: string; display_label: string | null; text: { value: string | null; } | null; } | null; }[]; }; object_path: { value: string | null; } | null; } | null; }[]; }; }, { ids?: (string | null)[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:fa4cd11e447de59f2fb7da429e4f8132 */ + /** @gql.tada/hash sha256:6fa58b215f63d72685e88a39dc526d05 */ "\n query actions($proposedChangeId: String!) {\n CoreProposedChangeAvailableActions(proposed_change_id: $proposedChangeId) {\n count\n edges {\n node {\n action\n available\n unavailability_reason\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreProposedChangeAvailableActions: { count: number; edges: { node: { action: string; available: boolean; unavailability_reason: string | null; }; }[]; }; }, { proposedChangeId: string; }, void>; - /** @gql.tada/hash sha256:ce8a3c343ef00b73203d243ee036a36e */ + /** @gql.tada/hash sha256:b6196896bc9dfc883149fa5b04db8018 */ "\n mutation ProposedChangeReview($proposedChangeId: String!, $decision: ProposedChangeApprovalDecision!) {\n CoreProposedChangeReview(data: { id: $proposedChangeId, decision: $decision }) {\n ok\n }\n }\n": TadaDocumentNode<{ CoreProposedChangeReview: { ok: boolean | null; } | null; }, { decision: "APPROVE" | "CANCEL_APPROVE" | "CANCEL_REJECT" | "REJECT"; proposedChangeId: string; }, void>; - /** @gql.tada/hash sha256:08c5143a5b72d882a4a784d8693f625d */ + /** @gql.tada/hash sha256:abeac9129b867e6044bbcfaa9347e12f */ "\n mutation CHECK_REPOSITORY_CONNECTIVITY($repositoryId: String!) {\n InfrahubRepositoryConnectivity(data: { id: $repositoryId }) {\n ok\n message\n }\n }\n": TadaDocumentNode<{ InfrahubRepositoryConnectivity: { ok: boolean; message: string; } | null; }, { repositoryId: string; }, void>; - /** @gql.tada/hash sha256:5a4f0a2024c50b846463fdc9af53a7c5 */ + /** @gql.tada/hash sha256:62d842ae8d5b800fd0676c82b9a9ba3e */ "\n query REPOSITORY_GROUP($nodeIds: [ID]) {\n CoreRepositoryGroup(repository__ids: $nodeIds) {\n edges {\n node {\n id\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreRepositoryGroup: { edges: { node: { id: string; } | null; }[]; }; }, { nodeIds?: (string | null)[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:cb381e1486adb021844c2b1581e70122 */ + /** @gql.tada/hash sha256:fd17ac56805b3600ccd5f613e48afbfb */ "\n mutation IMPORT_CURRENT_COMMIT($repositoryId: String!) {\n InfrahubRepositoryProcess(data: { id: $repositoryId }) {\n ok\n task {\n id\n }\n }\n }\n": TadaDocumentNode<{ InfrahubRepositoryProcess: { ok: boolean | null; task: { id: string | null; } | null; } | null; }, { repositoryId: string; }, void>; - /** @gql.tada/hash sha256:56a284f2ec1dbf59ebd32a4f7d0a6920 */ + /** @gql.tada/hash sha256:38a7ab3d8a37411bb8b028f657d22870 */ "\n mutation REIMPORT_LAST_COMMIT($repositoryId: String!) {\n InfrahubReadOnlyRepositoryImportLastCommit(data: { id: $repositoryId }) {\n ok\n task {\n id\n }\n }\n }\n": TadaDocumentNode<{ InfrahubReadOnlyRepositoryImportLastCommit: { ok: boolean | null; task: { id: string | null; } | null; } | null; }, { repositoryId: string; }, void>; - /** @gql.tada/hash sha256:408f42b4ebee22dd864196749483360f */ + /** @gql.tada/hash sha256:283f400fe1bf8201eab18644f115969d */ "\n query GET_NUMBER_POOLS($objectKinds: [String]) {\n CoreNumberPool(node__values: $objectKinds) {\n edges {\n node {\n id\n hfid\n display_label\n node {\n id\n value\n }\n node_attribute {\n id\n value\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ CoreNumberPool: { edges: { node: { id: string; hfid: string[] | null; display_label: string | null; node: { id: string | null; value: string | null; } | null; node_attribute: { id: string | null; value: string | null; } | null; } | null; }[]; }; }, { objectKinds?: (string | null)[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:7c4cdfae48ba5e61a08298390bc42299 */ + /** @gql.tada/hash sha256:ae690328a1fdecd1f667f1d332bdaa8b */ "\n query GET_POOL_UTILIZATION($poolId: String!) {\n InfrahubResourcePoolUtilization(pool_id: $poolId) {\n edges {\n node {\n id\n display_label\n kind\n weight\n utilization\n utilization_branches\n utilization_default_branch\n }\n }\n count\n utilization\n utilization_branches\n utilization_default_branch\n }\n }\n": TadaDocumentNode<{ InfrahubResourcePoolUtilization: { edges: { node: { id: string; display_label: string; kind: string; weight: unknown; utilization: number; utilization_branches: number; utilization_default_branch: number; }; }[]; count: unknown; utilization: number; utilization_branches: number; utilization_default_branch: number; }; }, { poolId: string; }, void>; - /** @gql.tada/hash sha256:7b571c1dc8253f9a676c2f6c176577e5 */ + /** @gql.tada/hash sha256:289517d403bff526da229f3a9d6e7d96 */ "\n query GET_RESOURCE_POOL_ALLOCATED(\n $poolId: String!\n $resourceId: String!\n $limit: Int!\n $offset: Int!\n ) {\n InfrahubResourcePoolAllocated(\n pool_id: $poolId\n resource_id: $resourceId\n limit: $limit\n offset: $offset\n ) {\n count\n edges {\n node {\n id\n display_label\n kind\n branch\n identifier\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubResourcePoolAllocated: { count: unknown; edges: { node: { id: string; display_label: string; kind: string; branch: string; identifier: string | null; }; }[]; }; }, { offset: number; limit: number; resourceId: string; poolId: string; }, void>; - /** @gql.tada/hash sha256:7c6390750907a96abd02a9a3f96c3762 */ + /** @gql.tada/hash sha256:880bd1390640420451bcbce80e5d1c90 */ "\n mutation DropdownAdd(\n $kind: String!\n $attribute: String!\n $dropdown: String!\n $label: String\n $color: String\n $description: String\n ) {\n SchemaDropdownAdd(\n data: {\n kind: $kind\n attribute: $attribute\n dropdown: $dropdown\n label: $label\n color: $color\n description: $description\n }\n ) {\n ok\n object {\n value\n label\n color\n description\n }\n }\n }\n": TadaDocumentNode<{ SchemaDropdownAdd: { ok: boolean | null; object: { value: string | null; label: string | null; color: string | null; description: string | null; } | null; } | null; }, { description?: string | null | undefined; color?: string | null | undefined; label?: string | null | undefined; dropdown: string; attribute: string; kind: string; }, void>; - /** @gql.tada/hash sha256:b609600b42aa1130d7cc0948938d58a3 */ + /** @gql.tada/hash sha256:61b42fb51d3a1efbfe5fbd6775221480 */ "\n mutation EnumAdd($kind: String!, $attribute: String!, $enum: String!) {\n SchemaEnumAdd(data: { kind: $kind, attribute: $attribute, enum: $enum }) {\n ok\n }\n }\n": TadaDocumentNode<{ SchemaEnumAdd: { ok: boolean | null; } | null; }, { enum: string; attribute: string; kind: string; }, void>; - /** @gql.tada/hash sha256:f3fa014dae7b7214473ab20c467b6eac */ + /** @gql.tada/hash sha256:5d5318563f3de0086e50e694573552ff */ "\n mutation DropdownDelete($kind: String!, $attribute: String!, $dropdown: String!) {\n SchemaDropdownRemove(data: { kind: $kind, attribute: $attribute, dropdown: $dropdown }) {\n ok\n }\n }\n": TadaDocumentNode<{ SchemaDropdownRemove: { ok: boolean | null; } | null; }, { dropdown: string; attribute: string; kind: string; }, void>; - /** @gql.tada/hash sha256:1bc50ce5d2bdb0f4107b4d4f9dd41bc3 */ + /** @gql.tada/hash sha256:3d6486267ee7d92e0ccc38a93befacf0 */ "\n mutation EnumDelete($kind: String!, $attribute: String!, $enum: String!) {\n SchemaEnumRemove(data: { kind: $kind, attribute: $attribute, enum: $enum }) {\n ok\n }\n }\n": TadaDocumentNode<{ SchemaEnumRemove: { ok: boolean | null; } | null; }, { enum: string; attribute: string; kind: string; }, void>; - /** @gql.tada/hash sha256:410c7296c7a43196fc797ebec5540460 */ + /** @gql.tada/hash sha256:07db1707d3617fe9b9ec1d26f0b48029 */ "\n mutation CANCEL_TASK($id: String!) {\n InfrahubTaskCancel(data: { id: $id }) {\n ok\n task {\n id\n }\n }\n }\n": TadaDocumentNode<{ InfrahubTaskCancel: { ok: boolean | null; task: { id: string | null; } | null; } | null; }, { id: string; }, void>; - /** @gql.tada/hash sha256:c07854cd50b3c40da0ca7d68d2646927 */ + /** @gql.tada/hash sha256:9d309b9e14477acfb8080ef87bbec8d1 */ "\n query TASK_DETAILS_CHECK(\n $ids: [String]\n $branch: String\n $workflow: [String]\n $state: [StateType]\n $relatedNodes: [String]\n ) {\n InfrahubTask(\n ids: $ids\n branch: $branch\n workflow: $workflow\n state: $state\n related_node__ids: $relatedNodes\n ) {\n count\n }\n }\n": TadaDocumentNode<{ InfrahubTask: { count: number; }; }, { relatedNodes?: (string | null)[] | null | undefined; state?: ("CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null)[] | null | undefined; workflow?: (string | null)[] | null | undefined; branch?: string | null | undefined; ids?: (string | null)[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:6f4d0c709a3bc01796e4411eb2527834 */ + /** @gql.tada/hash sha256:7edc06c530f75b3564c8dbc336542053 */ "\n query TASKS_BRANCH_STATUS_COUNT($branch: String!) {\n InfrahubTaskBranchStatus(branch: $branch) {\n count\n }\n }\n": TadaDocumentNode<{ InfrahubTaskBranchStatus: { count: number; }; }, { branch: string; }, void>; - /** @gql.tada/hash sha256:e73ed3510b77968f19e85a129b0b675e */ + /** @gql.tada/hash sha256:03b3c3c7e919983935d273bba235ea91 */ "\n query TASK_COUNT(\n $search: String\n $branchName: String\n $state: [StateType]\n $relatedNodeIds: [String]\n ) {\n InfrahubTask(\n q: $search\n branch: $branchName\n state: $state\n related_node__ids: $relatedNodeIds\n ) {\n count\n }\n }\n": TadaDocumentNode<{ InfrahubTask: { count: number; }; }, { relatedNodeIds?: (string | null)[] | null | undefined; state?: ("CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null)[] | null | undefined; branchName?: string | null | undefined; search?: string | null | undefined; }, void>; - /** @gql.tada/hash sha256:a851535d1aa59426996aa5256e21d952 */ + /** @gql.tada/hash sha256:9d81dd29bb9e3c894714372c48a6b8a8 */ "\n query GET_TASK_DETAILS(\n $ids: [String]\n $branch: String\n $workflow: [String]\n $relatedNodes: [String]\n ) {\n InfrahubTask(\n ids: $ids\n branch: $branch\n workflow: $workflow\n related_node__ids: $relatedNodes\n ) {\n count\n edges {\n node {\n id\n title\n branch\n related_node\n related_nodes {\n id\n kind\n }\n state\n progress\n created_at\n updated_at\n logs {\n edges {\n node {\n id\n message\n severity\n timestamp\n }\n }\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubTask: { count: number; edges: { node: { __typename?: "TaskNode" | undefined; id: string; title: string; branch: string | null; related_node: string | null; related_nodes: ({ id: string; kind: string; } | null)[] | null; state: "CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null; progress: number | null; created_at: string; updated_at: string; logs: { edges: { node: { id: string | null; message: string; severity: string; timestamp: string; } | null; }[]; } | null; } | { __typename?: "WebhookDeliveryTask" | undefined; id: string; title: string; branch: string | null; related_node: string | null; related_nodes: ({ id: string; kind: string; } | null)[] | null; state: "CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null; progress: number | null; created_at: string; updated_at: string; logs: { edges: { node: { id: string | null; message: string; severity: string; timestamp: string; } | null; }[]; } | null; } | null; }[]; }; }, { relatedNodes?: (string | null)[] | null | undefined; workflow?: (string | null)[] | null | undefined; branch?: string | null | undefined; ids?: (string | null)[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:a79a8fddefe8c3ef9df3a6ed3fc47a9d */ + /** @gql.tada/hash sha256:db31a5e654405606890c8eb8c1d63fce */ "\n query GET_TASK_DETAILS_TITLE_QUERY($ids: [String!]) {\n InfrahubTask(ids: $ids) {\n count\n edges {\n node {\n id\n title\n state\n available_actions {\n action\n available\n unavailability_reason\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubTask: { count: number; edges: { node: { __typename?: "TaskNode" | undefined; id: string; title: string; state: "CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null; available_actions: { action: "CANCEL" | "RETRY"; available: boolean; unavailability_reason: string | null; }[]; } | { __typename?: "WebhookDeliveryTask" | undefined; id: string; title: string; state: "CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null; available_actions: { action: "CANCEL" | "RETRY"; available: boolean; unavailability_reason: string | null; }[]; } | null; }[]; }; }, { ids?: string[] | null | undefined; }, void>; - /** @gql.tada/hash sha256:4aecb0681a80bca2c71a405dc8c9e814 */ + /** @gql.tada/hash sha256:4c4879cfbecaa5fd34005d1483742bae */ "\n query GET_TASK_LIST(\n $offset: Int\n $limit: Int\n $search: String\n $branchName: String\n $state: [StateType]\n $relatedNodeIds: [String]\n ) {\n InfrahubTask(\n offset: $offset\n limit: $limit\n q: $search\n branch: $branchName\n state: $state\n related_node__ids: $relatedNodeIds\n ) {\n count\n edges {\n node {\n id\n branch\n related_nodes {\n id\n kind\n }\n title\n updated_at\n state\n progress\n workflow\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubTask: { count: number; edges: { node: { __typename?: "TaskNode" | undefined; id: string; branch: string | null; related_nodes: ({ id: string; kind: string; } | null)[] | null; title: string; updated_at: string; state: "CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null; progress: number | null; workflow: string | null; } | { __typename?: "WebhookDeliveryTask" | undefined; id: string; branch: string | null; related_nodes: ({ id: string; kind: string; } | null)[] | null; title: string; updated_at: string; state: "CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null; progress: number | null; workflow: string | null; } | null; }[]; }; }, { relatedNodeIds?: (string | null)[] | null | undefined; state?: ("CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null)[] | null | undefined; branchName?: string | null | undefined; search?: string | null | undefined; limit?: number | null | undefined; offset?: number | null | undefined; }, void>; - /** @gql.tada/hash sha256:ab5d879661f89b237a2d90a15605375f */ + /** @gql.tada/hash sha256:7ddacd42d7a82b556b629f90c4cca15d */ "\n query GET_TASKS_HOMEPAGE($limit: Int, $branchName: String!, $states: [StateType]) {\n InfrahubTask(limit: $limit, branch: $branchName, state: $states) {\n count\n edges {\n node {\n id\n branch\n title\n updated_at\n state\n related_nodes {\n id\n kind\n }\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubTask: { count: number; edges: { node: { __typename?: "TaskNode" | undefined; id: string; branch: string | null; title: string; updated_at: string; state: "CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null; related_nodes: ({ id: string; kind: string; } | null)[] | null; } | { __typename?: "WebhookDeliveryTask" | undefined; id: string; branch: string | null; title: string; updated_at: string; state: "CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null; related_nodes: ({ id: string; kind: string; } | null)[] | null; } | null; }[]; }; }, { states?: ("CANCELLED" | "CANCELLING" | "COMPLETED" | "CRASHED" | "FAILED" | "PAUSED" | "PENDING" | "RUNNING" | "SCHEDULED" | null)[] | null | undefined; branchName: string; limit?: number | null | undefined; }, void>; - /** @gql.tada/hash sha256:3c1f7c8db110dea4c17c637712ecee23 */ + /** @gql.tada/hash sha256:fada3831264b5c48d3312638af6ebff1 */ "\n mutation RETRY_TASK($id: String!) {\n InfrahubTaskRetry(data: { id: $id }) {\n ok\n task {\n id\n }\n }\n }\n": TadaDocumentNode<{ InfrahubTaskRetry: { ok: boolean | null; task: { id: string | null; } | null; } | null; }, { id: string; }, void>; - /** @gql.tada/hash sha256:a4d9c5cf51713665497d8d1df86265f5 */ + /** @gql.tada/hash sha256:64673823a73351d1f736b07b3b1ecda0 */ "\n mutation InfrahubAccountTokenCreate($tokenName: String!, $tokenExpirationDate: String) {\n InfrahubAccountTokenCreate(data: { name: $tokenName, expiration: $tokenExpirationDate }) {\n object {\n id\n token {\n value\n }\n }\n ok\n }\n }\n": TadaDocumentNode<{ InfrahubAccountTokenCreate: { object: { id: string; token: { value: string; } | null; } | null; ok: boolean | null; } | null; }, { tokenExpirationDate?: string | null | undefined; tokenName: string; }, void>; - /** @gql.tada/hash sha256:a05b41d309c1ed850c915a076aef079d */ + /** @gql.tada/hash sha256:84dd466ace63d019824e0e7193ccd43d */ "\n query GetAccountProfile {\n AccountProfile {\n id\n display_label\n is_externally_managed\n name {\n value\n }\n label {\n value\n }\n description {\n value\n }\n }\n }\n": TadaDocumentNode<{ AccountProfile: { __typename?: "CoreAccount" | undefined; id: string | null; display_label: string | null; is_externally_managed: boolean; name: { value: string | null; } | null; label: { value: string | null; } | null; description: { value: string | null; } | null; } | null; }, {}, void>; - /** @gql.tada/hash sha256:9a78809262465c570a8c6d9c52d458ef */ + /** @gql.tada/hash sha256:dcf30d8a0afbf68ed697e06736710ca3 */ "\n query InfrahubAccountToken {\n InfrahubAccountToken {\n count\n edges {\n node {\n id\n name\n expiration\n __typename\n }\n }\n }\n }\n": TadaDocumentNode<{ InfrahubAccountToken: { count: number; edges: { node: { id: string; name: string | null; expiration: string | null; __typename: "AccountTokenNode"; }; }[]; }; }, {}, void>; - /** @gql.tada/hash sha256:7ea558c595803aa3d4180d9ee8f203dd */ + /** @gql.tada/hash sha256:16b48f884dd87e7b53a129df2d13a001 */ "\n mutation UPDATE_ACCOUNT_PASSWORD($password: String!) {\n InfrahubAccountSelfUpdate(data: { password: $password }) {\n ok\n }\n }\n": TadaDocumentNode<{ InfrahubAccountSelfUpdate: { ok: boolean | null; } | null; }, { password: string; }, void>; } diff --git a/frontend/app/src/shared/api/graphql/generated/graphql-env.d.ts b/frontend/app/src/shared/api/graphql/generated/graphql-env.d.ts index 3e376b8e9c7..178bc569e13 100644 --- a/frontend/app/src/shared/api/graphql/generated/graphql-env.d.ts +++ b/frontend/app/src/shared/api/graphql/generated/graphql-env.d.ts @@ -18,7 +18,7 @@ export type introspection_types = { 'ActionAvailabilityEdge': { kind: 'OBJECT'; name: 'ActionAvailabilityEdge'; fields: { 'node': { name: 'node'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ActionAvailability'; ofType: null; }; } }; }; }; 'AnyAttribute': { kind: 'OBJECT'; name: 'AnyAttribute'; fields: { 'id': { name: 'id'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'is_default': { name: 'is_default'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'is_from_profile': { name: 'is_from_profile'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'is_protected': { name: 'is_protected'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'owner': { name: 'owner'; type: { kind: 'INTERFACE'; name: 'LineageOwner'; ofType: null; } }; 'permissions': { name: 'permissions'; type: { kind: 'OBJECT'; name: 'PermissionType'; ofType: null; } }; 'source': { name: 'source'; type: { kind: 'INTERFACE'; name: 'LineageSource'; ofType: null; } }; 'updated_at': { name: 'updated_at'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'updated_by': { name: 'updated_by'; type: { kind: 'INTERFACE'; name: 'CoreGenericAccount'; ofType: null; } }; 'value': { name: 'value'; type: { kind: 'SCALAR'; name: 'GenericScalar'; ofType: null; } }; }; }; 'ArtifactEvent': { kind: 'OBJECT'; name: 'ArtifactEvent'; fields: { 'account_id': { name: 'account_id'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'artifact_definition_id': { name: 'artifact_definition_id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'branch': { name: 'branch'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'checksum': { name: 'checksum'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'checksum_previous': { name: 'checksum_previous'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'event': { name: 'event'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'has_children': { name: 'has_children'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; }; } }; 'id': { name: 'id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'level': { name: 'level'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'occurred_at': { name: 'occurred_at'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; }; } }; 'parent_id': { name: 'parent_id'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'primary_node': { name: 'primary_node'; type: { kind: 'OBJECT'; name: 'RelatedNode'; ofType: null; } }; 'related_nodes': { name: 'related_nodes'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'RelatedNode'; ofType: null; }; }; }; } }; 'storage_id': { name: 'storage_id'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; 'storage_id_previous': { name: 'storage_id_previous'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; }; }; - 'AttributeInterface': { kind: 'INTERFACE'; name: 'AttributeInterface'; fields: { 'is_default': { name: 'is_default'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'is_protected': { name: 'is_protected'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'updated_at': { name: 'updated_at'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; }; possibleTypes: 'AnyAttribute' | 'CheckboxAttribute' | 'Dropdown' | 'IPHost' | 'IPNetwork' | 'JSONAttribute' | 'ListAttribute' | 'MacAddress' | 'NumberAttribute' | 'TextAttribute'; }; + 'AttributeInterface': { kind: 'INTERFACE'; name: 'AttributeInterface'; fields: { 'is_default': { name: 'is_default'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'is_protected': { name: 'is_protected'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'updated_at': { name: 'updated_at'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; }; possibleTypes: 'AnyAttribute' | 'CheckboxAttribute' | 'Dropdown' | 'IPAddress' | 'IPHost' | 'IPNetwork' | 'JSONAttribute' | 'ListAttribute' | 'MacAddress' | 'NumberAttribute' | 'TextAttribute'; }; 'AvailableActions': { kind: 'OBJECT'; name: 'AvailableActions'; fields: { 'count': { name: 'count'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; } }; 'edges': { name: 'edges'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'LIST'; name: never; ofType: { kind: 'NON_NULL'; name: never; ofType: { kind: 'OBJECT'; name: 'ActionAvailabilityEdge'; ofType: null; }; }; }; } }; }; }; 'BigInt': unknown; 'Boolean': unknown; @@ -714,6 +714,7 @@ export type introspection_types = { 'HttpRequest': { kind: 'OBJECT'; name: 'HttpRequest'; fields: { 'headers': { name: 'headers'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'GenericScalar'; ofType: null; }; } }; 'url': { name: 'url'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; }; }; 'HttpResponse': { kind: 'OBJECT'; name: 'HttpResponse'; fields: { 'body': { name: 'body'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'latency_ms': { name: 'latency_ms'; type: { kind: 'SCALAR'; name: 'Float'; ofType: null; } }; 'status_code': { name: 'status_code'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; }; }; 'ID': unknown; + 'IPAddress': { kind: 'OBJECT'; name: 'IPAddress'; fields: { 'id': { name: 'id'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'is_default': { name: 'is_default'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'is_from_profile': { name: 'is_from_profile'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'is_protected': { name: 'is_protected'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; 'owner': { name: 'owner'; type: { kind: 'INTERFACE'; name: 'LineageOwner'; ofType: null; } }; 'permissions': { name: 'permissions'; type: { kind: 'OBJECT'; name: 'PermissionType'; ofType: null; } }; 'source': { name: 'source'; type: { kind: 'INTERFACE'; name: 'LineageSource'; ofType: null; } }; 'updated_at': { name: 'updated_at'; type: { kind: 'SCALAR'; name: 'DateTime'; ofType: null; } }; 'updated_by': { name: 'updated_by'; type: { kind: 'INTERFACE'; name: 'CoreGenericAccount'; ofType: null; } }; 'value': { name: 'value'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; } }; 'version': { name: 'version'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; } }; }; }; 'IPAddressGetNextAvailable': { kind: 'OBJECT'; name: 'IPAddressGetNextAvailable'; fields: { 'address': { name: 'address'; type: { kind: 'NON_NULL'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; } }; }; }; 'IPAddressPoolGetResource': { kind: 'OBJECT'; name: 'IPAddressPoolGetResource'; fields: { 'node': { name: 'node'; type: { kind: 'OBJECT'; name: 'PoolAllocatedNode'; ofType: null; } }; 'ok': { name: 'ok'; type: { kind: 'SCALAR'; name: 'Boolean'; ofType: null; } }; }; }; 'IPAddressPoolGetResourceInput': { kind: 'INPUT_OBJECT'; name: 'IPAddressPoolGetResourceInput'; isOneOf: false; inputFields: [{ name: 'address_type'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'data'; type: { kind: 'SCALAR'; name: 'FixedGenericScalar'; ofType: null; }; defaultValue: null }, { name: 'hfid'; type: { kind: 'LIST'; name: never; ofType: { kind: 'SCALAR'; name: 'String'; ofType: null; }; }; defaultValue: null }, { name: 'id'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'identifier'; type: { kind: 'SCALAR'; name: 'String'; ofType: null; }; defaultValue: null }, { name: 'prefix_length'; type: { kind: 'SCALAR'; name: 'Int'; ofType: null; }; defaultValue: null }]; }; diff --git a/frontend/app/src/shared/api/rest/types.generated.ts b/frontend/app/src/shared/api/rest/types.generated.ts index b9af9e9c7e7..8e610c2751a 100644 --- a/frontend/app/src/shared/api/rest/types.generated.ts +++ b/frontend/app/src/shared/api/rest/types.generated.ts @@ -873,7 +873,7 @@ export interface components { * @description Defines the type of the attribute. (enum property replaced by openapi-typescript) * @enum {string} */ - kind: "Any" | "Bandwidth" | "Boolean" | "Checkbox" | "Color" | "DateTime" | "Dropdown" | "Email" | "File" | "HashedPassword" | "ID" | "IPHost" | "IPNetwork" | "JSON" | "MacAddress" | "Password" | "URL"; + kind: "Any" | "Bandwidth" | "Boolean" | "Checkbox" | "Color" | "DateTime" | "Dropdown" | "Email" | "File" | "HashedPassword" | "ID" | "IPAddress" | "IPHost" | "IPNetwork" | "JSON" | "MacAddress" | "Password" | "URL"; /** * Enum * @description Define a list of valid values for the attribute. @@ -995,7 +995,7 @@ export interface components { * @description Defines the type of the attribute. (enum property replaced by openapi-typescript) * @enum {string} */ - kind: "Any" | "Bandwidth" | "Boolean" | "Checkbox" | "Color" | "DateTime" | "Dropdown" | "Email" | "File" | "HashedPassword" | "ID" | "IPHost" | "IPNetwork" | "JSON" | "MacAddress" | "Password" | "URL"; + kind: "Any" | "Bandwidth" | "Boolean" | "Checkbox" | "Color" | "DateTime" | "Dropdown" | "Email" | "File" | "HashedPassword" | "ID" | "IPAddress" | "IPHost" | "IPNetwork" | "JSON" | "MacAddress" | "Password" | "URL"; /** * Enum * @description Define a list of valid values for the attribute. diff --git a/frontend/app/src/shared/components/form/dynamic-form.tsx b/frontend/app/src/shared/components/form/dynamic-form.tsx index e534eed1a5f..142fcb3f9d3 100644 --- a/frontend/app/src/shared/components/form/dynamic-form.tsx +++ b/frontend/app/src/shared/components/form/dynamic-form.tsx @@ -109,6 +109,7 @@ export const DynamicField = (props: DynamicFieldProps) => { case ATTRIBUTE_KIND.ID: case ATTRIBUTE_KIND.IP_HOST: case ATTRIBUTE_KIND.IP_NETWORK: + case ATTRIBUTE_KIND.IP_ADDRESS: case ATTRIBUTE_KIND.MAC_ADDRESS: case ATTRIBUTE_KIND.TEXT: case ATTRIBUTE_KIND.URL: { diff --git a/frontend/app/src/shared/components/form/utils/getFormFieldFromAttribute.ts b/frontend/app/src/shared/components/form/utils/getFormFieldFromAttribute.ts index 96da29e2910..0d02dcf8018 100644 --- a/frontend/app/src/shared/components/form/utils/getFormFieldFromAttribute.ts +++ b/frontend/app/src/shared/components/form/utils/getFormFieldFromAttribute.ts @@ -30,6 +30,7 @@ import type { NumberAttributeParameters, TextAttributeParameters, } from "@/entities/schema/domain/model/schema"; +import { validateIpAddressAttribute } from "@/entities/schema/domain/rules/validation/validate-ip-address-attribute"; import { validateNumberAttribute } from "@/entities/schema/domain/rules/validation/validate-number-attribute"; import { validateTextAttribute } from "@/entities/schema/domain/rules/validation/validate-text-attribute"; @@ -119,6 +120,15 @@ export const getFormFieldFromAttribute = ({ } } + // IPAddress has no parameters, so this check sits outside the block above + if (attributeKind === ATTRIBUTE_KIND.IP_ADDRESS) { + const validation = validateIpAddressAttribute( + { isRequired: !attributeSchema.optional }, + formFieldValue.value as string | null + ); + return validation.success || validation.error; + } + if (attributeSchema.optional) return true; return isRequired(formFieldValue); }, diff --git a/python_sdk b/python_sdk index 681b458cd32..204f169a2a6 160000 --- a/python_sdk +++ b/python_sdk @@ -1 +1 @@ -Subproject commit 681b458cd324c6eec746bb225135cbb7dd99640e +Subproject commit 204f169a2a67712b5b3a5b65cc17f13ac668ee27 diff --git a/schema/openapi.json b/schema/openapi.json index 831973a74bb..bb15a0e6d8a 100644 --- a/schema/openapi.json +++ b/schema/openapi.json @@ -3059,6 +3059,7 @@ "Bandwidth", "IPHost", "IPNetwork", + "IPAddress", "Boolean", "Checkbox", "JSON", @@ -3335,6 +3336,7 @@ "Bandwidth", "IPHost", "IPNetwork", + "IPAddress", "Boolean", "Checkbox", "JSON", @@ -3802,6 +3804,7 @@ "File": "#/components/schemas/GenericAttributeRead", "HashedPassword": "#/components/schemas/GenericAttributeRead", "ID": "#/components/schemas/GenericAttributeRead", + "IPAddress": "#/components/schemas/GenericAttributeRead", "IPHost": "#/components/schemas/GenericAttributeRead", "IPNetwork": "#/components/schemas/GenericAttributeRead", "JSON": "#/components/schemas/GenericAttributeRead", @@ -4124,6 +4127,7 @@ "File": "#/components/schemas/GenericAttributeWrite", "HashedPassword": "#/components/schemas/GenericAttributeWrite", "ID": "#/components/schemas/GenericAttributeWrite", + "IPAddress": "#/components/schemas/GenericAttributeWrite", "IPHost": "#/components/schemas/GenericAttributeWrite", "IPNetwork": "#/components/schemas/GenericAttributeWrite", "JSON": "#/components/schemas/GenericAttributeWrite", @@ -5299,6 +5303,7 @@ "File": "#/components/schemas/GenericAttributeWrite", "HashedPassword": "#/components/schemas/GenericAttributeWrite", "ID": "#/components/schemas/GenericAttributeWrite", + "IPAddress": "#/components/schemas/GenericAttributeWrite", "IPHost": "#/components/schemas/GenericAttributeWrite", "IPNetwork": "#/components/schemas/GenericAttributeWrite", "JSON": "#/components/schemas/GenericAttributeWrite", @@ -5567,6 +5572,7 @@ "File": "#/components/schemas/GenericAttributeRead", "HashedPassword": "#/components/schemas/GenericAttributeRead", "ID": "#/components/schemas/GenericAttributeRead", + "IPAddress": "#/components/schemas/GenericAttributeRead", "IPHost": "#/components/schemas/GenericAttributeRead", "IPNetwork": "#/components/schemas/GenericAttributeRead", "JSON": "#/components/schemas/GenericAttributeRead", @@ -5910,6 +5916,7 @@ "File": "#/components/schemas/GenericAttributeWrite", "HashedPassword": "#/components/schemas/GenericAttributeWrite", "ID": "#/components/schemas/GenericAttributeWrite", + "IPAddress": "#/components/schemas/GenericAttributeWrite", "IPHost": "#/components/schemas/GenericAttributeWrite", "IPNetwork": "#/components/schemas/GenericAttributeWrite", "JSON": "#/components/schemas/GenericAttributeWrite", @@ -7429,6 +7436,7 @@ "File": "#/components/schemas/GenericAttributeRead", "HashedPassword": "#/components/schemas/GenericAttributeRead", "ID": "#/components/schemas/GenericAttributeRead", + "IPAddress": "#/components/schemas/GenericAttributeRead", "IPHost": "#/components/schemas/GenericAttributeRead", "IPNetwork": "#/components/schemas/GenericAttributeRead", "JSON": "#/components/schemas/GenericAttributeRead", @@ -8702,6 +8710,7 @@ "File": "#/components/schemas/GenericAttributeRead", "HashedPassword": "#/components/schemas/GenericAttributeRead", "ID": "#/components/schemas/GenericAttributeRead", + "IPAddress": "#/components/schemas/GenericAttributeRead", "IPHost": "#/components/schemas/GenericAttributeRead", "IPNetwork": "#/components/schemas/GenericAttributeRead", "JSON": "#/components/schemas/GenericAttributeRead", diff --git a/schema/schema.graphql b/schema/schema.graphql index 4375ac846b6..e2f50cbd512 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -8628,6 +8628,24 @@ type HttpResponse { status_code: Int } +"""Attribute of type IPAddress""" +type IPAddress implements AttributeInterface { + id: String + is_default: Boolean + is_from_profile: Boolean + is_protected: Boolean + owner: LineageOwner + permissions: PermissionType + source: LineageSource + """ + Date/Time when the attribute was last modified by a user or a system task + """ + updated_at: DateTime + updated_by: CoreGenericAccount + value: String + version: Int +} + type IPAddressGetNextAvailable { address: String! } diff --git a/tasks/backend.py b/tasks/backend.py index a711f2c3f7a..a2cd0b52c1c 100644 --- a/tasks/backend.py +++ b/tasks/backend.py @@ -550,6 +550,7 @@ def inherit_from(subject: str) -> SchemaAttribute: "Bandwidth": "BANDWIDTH", "IPHost": "IPHOST", "IPNetwork": "IPNETWORK", + "IPAddress": "IPADDRESS", "Boolean": "BOOLEAN", "Checkbox": "CHECKBOX", "List": "LIST", diff --git a/tasks/shared.py b/tasks/shared.py index b5664608f7e..31bf7a94d8a 100644 --- a/tasks/shared.py +++ b/tasks/shared.py @@ -145,6 +145,7 @@ class Namespace(StrEnum): "dropdown": "str", "enum": "str", "hashedpassword": "str", + "ipaddress": "str", "iphost": "str", "ipnetwork": "str", "json": "dict", From 3ea1eba098f12e4a23e4b2c0bcda2ae812dd50ca Mon Sep 17 00:00:00 2001 From: Pol Michel <40861490+polmichel@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:33:23 +0200 Subject: [PATCH 69/75] docs(specs): record the selective-regen known limitations tracked under IFC-2950 (#10102) Add three known limitations of the merge follow-up regeneration to the performance-scenarios spec, each carrying its tracking ticket and the release-1.11 code reference: - relationship-reached data widens selection to the whole target group (IFC-2946) - no size ceiling on the serialized merge diff summary versus the cache backend per-value limit (IFC-2943) - each selective-regen merge and rebase leaks an orphaned persisted diff root (IFC-2941) Co-authored-by: Claude Opus 4.8 (1M context) --- .../ifc-2704-incremental-merge-regen/performance-scenarios.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dev/specs/ifc-2704-incremental-merge-regen/performance-scenarios.md b/dev/specs/ifc-2704-incremental-merge-regen/performance-scenarios.md index bfa39fa6362..1981afe78f8 100644 --- a/dev/specs/ifc-2704-incremental-merge-regen/performance-scenarios.md +++ b/dev/specs/ifc-2704-incremental-merge-regen/performance-scenarios.md @@ -122,3 +122,6 @@ choice is correctness over precision. | Limitation | Evidence | |------------|----------| | A composite artifact that inlines an upstream artifact's content (content composition) is not refreshed after a direct merge that changes the upstream. The composite keeps stale inlined content. | `integration_docker/test_merge_composition_cascade.py::TestMergeCompositionCascade::test_direct_merge_refreshes_inlined_section` (asserts the fresh content, so it fails while the gap is unfixed; local-only, so it does not gate CI); confirmed live on the demo stack | +| Narrowing reduces to the impacted members only when the changed field is a root attribute of the query's target object, or a group-membership change. When the changed data is reached through a relationship (a traversed kind in the query), or the query does not resolve to a single object per root, the resolver cannot map the change back to a member and widens to the whole target group. The behavior is safe (over-execution only), but it negates the per-member benefit for the common case, since most artifacts and generators read relationship data (a device's interfaces, IP addresses, BGP sessions). | [IFC-2946](https://opsmill.atlassian.net/browse/IFC-2946); `QueryImpactClassifier._must_widen` (`backend/infrahub/core/regeneration/impact_classifier.py`) widens on a non-unique-target query or a change on a `traversed_kinds` kind; validated live on release-1.11 | +| The merge diff summary is serialized into a single cached value with no size ceiling. A large merge can produce a summary that exceeds the cache backend's per-value limit (NATS JetStream rejects above ~1 MB; Redis accepts up to 512 MB with memory and latency pressure), so an oversized write fails on NATS and falls back to full regeneration, while on Redis it succeeds under pressure. | [IFC-2943](https://opsmill.atlassian.net/browse/IFC-2943); measured on the live serializer at ~803 bytes per changed node, so ~1,245 changed nodes reach 1 MB, with serialization staying cheap (~3.5 ms at 1 MB), so the binding constraint is the backend value-size limit, not CPU | +| Every merge that runs selective regeneration persists a diff root to capture the post-generator output, then reads it once and discards it without removing it, so each such merge leaves one orphaned diff root in the graph; the rebase path leaks the same way. | [IFC-2941](https://opsmill.atlassian.net/browse/IFC-2941); `GeneratorTrackingGroupDiffCapturer` (`backend/infrahub/core/merge/selective_regen/generator_diff_capturer.py`) saves via `create_or_update_arbitrary_timeframe_diff` under a bare UUID name, so the root carries no tracking id to supersede or wipe | From ed60894e767e9dacefd1d2d75247bc58f6aa2261 Mon Sep 17 00:00:00 2001 From: Bilal ABBAD Date: Fri, 31 Jul 2026 21:51:46 +0200 Subject: [PATCH 70/75] feat(frontend): allow users to customize the sorting of proposed changes list (#10104) --- changelog/9915.added.md | 1 + .../cells/table-column-header.tsx | 2 +- .../api/get-proposed-changes-from-api.ts | 11 +- .../domain/model/proposed-change-sort.ts | 6 + .../compute-proposed-change-sort.test.ts | 28 ++++ .../rules/compute-proposed-change-sort.ts | 7 + .../ui/proposed-change-item.tsx | 20 ++- .../ui/proposed-change-table-filter.tsx | 73 --------- .../ui/proposed-changes-manager-toolbar.tsx | 3 + .../ui/proposed-changes-table-filters.tsx | 40 +++-- .../ui/proposed-changes-table.tsx | 4 + .../proposed-changes.query-keys.test.ts | 23 ++- .../ui/queries/proposed-changes.query-keys.ts | 6 +- .../test_proposed_changes_ordering.py | 147 ++++++++++++++++-- 14 files changed, 255 insertions(+), 116 deletions(-) create mode 100644 changelog/9915.added.md create mode 100644 frontend/app/src/entities/proposed-changes/domain/model/proposed-change-sort.ts create mode 100644 frontend/app/src/entities/proposed-changes/domain/rules/compute-proposed-change-sort.test.ts create mode 100644 frontend/app/src/entities/proposed-changes/domain/rules/compute-proposed-change-sort.ts delete mode 100644 frontend/app/src/entities/proposed-changes/ui/proposed-change-table-filter.tsx diff --git a/changelog/9915.added.md b/changelog/9915.added.md new file mode 100644 index 00000000000..9e7ea0b86f2 --- /dev/null +++ b/changelog/9915.added.md @@ -0,0 +1 @@ +Added a Sort picker to the proposed changes list to order it by any sortable field, including creation and update dates. The list now defaults to newest created first. diff --git a/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-column-header.tsx b/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-column-header.tsx index 7b192d65fab..a8fac8c1470 100644 --- a/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-column-header.tsx +++ b/frontend/app/src/entities/nodes/object/ui/object-table/cells/table-column-header.tsx @@ -229,7 +229,7 @@ function ColumnHeaderMenu({ {label} - + {activeSort && (activeSort.direction === SORT_DIRECTION.DESC ? ( <> diff --git a/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-from-api.ts b/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-from-api.ts index 7dcec15e71b..95704da5ba5 100644 --- a/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-from-api.ts +++ b/frontend/app/src/entities/proposed-changes/api/get-proposed-changes-from-api.ts @@ -1,16 +1,19 @@ -import { EnumType, jsonToGraphQLQuery } from "json-to-graphql-query"; +import { jsonToGraphQLQuery } from "json-to-graphql-query"; import { graphql, graphqlClient } from "@/shared/api/graphql/client"; import { addAttributesToRequest, addFiltersToRequest, + addOrderByToRequest, addRelationshipsToRequest, } from "@/shared/api/graphql/utils"; import type { PaginationParams } from "@/shared/api/types"; import { DEFAULT_PAGE_SIZE } from "@/shared/utils/pagination"; import type { Filter } from "@/entities/nodes/filters/domain/model/filter"; +import type { Sort } from "@/entities/nodes/sort/domain/model/sort"; import { PROPOSED_CHANGE_OBJECT } from "@/entities/proposed-changes/domain/model/proposed-change"; +import { PROPOSED_CHANGE_DEFAULT_SORT } from "@/entities/proposed-changes/domain/model/proposed-change-sort"; import type { AttributeSchema, ModelSchema, @@ -20,6 +23,7 @@ import type { export interface ProposedChangesFromApiParams extends PaginationParams { schema: ModelSchema; filters?: Array; + sort?: Array; getAttributesVisible: (attributes: AttributeSchema[]) => AttributeSchema[]; getRelationshipsVisible: (relationships: RelationshipSchema[]) => RelationshipSchema[]; } @@ -29,6 +33,7 @@ export const getProposedChangesFromApi = async ({ limit = DEFAULT_PAGE_SIZE, offset, filters, + sort, getAttributesVisible, getRelationshipsVisible, }: ProposedChangesFromApiParams) => { @@ -44,9 +49,7 @@ export const getProposedChangesFromApi = async ({ __args: { limit, offset, - order: { - by: [{ field: "node_metadata__created_at", direction: new EnumType("DESC") }], - }, + ...addOrderByToRequest(sort?.length ? sort : [PROPOSED_CHANGE_DEFAULT_SORT]), ...(filters ? addFiltersToRequest(filters) : {}), }, count: true, diff --git a/frontend/app/src/entities/proposed-changes/domain/model/proposed-change-sort.ts b/frontend/app/src/entities/proposed-changes/domain/model/proposed-change-sort.ts new file mode 100644 index 00000000000..b0f4c64629c --- /dev/null +++ b/frontend/app/src/entities/proposed-changes/domain/model/proposed-change-sort.ts @@ -0,0 +1,6 @@ +import { SORT_DIRECTION, type Sort } from "@/entities/nodes/sort/domain/model/sort"; + +export const PROPOSED_CHANGE_DEFAULT_SORT: Sort = { + field: "node_metadata__created_at", + direction: SORT_DIRECTION.DESC, +}; diff --git a/frontend/app/src/entities/proposed-changes/domain/rules/compute-proposed-change-sort.test.ts b/frontend/app/src/entities/proposed-changes/domain/rules/compute-proposed-change-sort.test.ts new file mode 100644 index 00000000000..c70b8a5400e --- /dev/null +++ b/frontend/app/src/entities/proposed-changes/domain/rules/compute-proposed-change-sort.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "vitest"; + +import type { Sort } from "@/entities/nodes/sort/domain/model/sort"; +import { computeProposedChangeSort } from "@/entities/proposed-changes/domain/rules/compute-proposed-change-sort"; + +describe("computeProposedChangeSort", () => { + test("falls back to newest created first when nothing is applied", () => { + // GIVEN + const appliedSort: Sort[] = []; + + // WHEN + const sort = computeProposedChangeSort(appliedSort); + + // THEN + expect(sort).toEqual([{ field: "node_metadata__created_at", direction: "DESC" }]); + }); + + test("keeps the applied sort untouched", () => { + // GIVEN + const appliedSort: Sort[] = [{ field: "node_metadata__updated_at", direction: "ASC" }]; + + // WHEN + const sort = computeProposedChangeSort(appliedSort); + + // THEN + expect(sort).toEqual(appliedSort); + }); +}); diff --git a/frontend/app/src/entities/proposed-changes/domain/rules/compute-proposed-change-sort.ts b/frontend/app/src/entities/proposed-changes/domain/rules/compute-proposed-change-sort.ts new file mode 100644 index 00000000000..24dffa2f95d --- /dev/null +++ b/frontend/app/src/entities/proposed-changes/domain/rules/compute-proposed-change-sort.ts @@ -0,0 +1,7 @@ +import type { Sort } from "@/entities/nodes/sort/domain/model/sort"; +import { PROPOSED_CHANGE_DEFAULT_SORT } from "@/entities/proposed-changes/domain/model/proposed-change-sort"; + +/** The order the list actually queries: what the user chose, else the list default. */ +export function computeProposedChangeSort(sort: Sort[]): Sort[] { + return sort.length > 0 ? sort : [PROPOSED_CHANGE_DEFAULT_SORT]; +} diff --git a/frontend/app/src/entities/proposed-changes/ui/proposed-change-item.tsx b/frontend/app/src/entities/proposed-changes/ui/proposed-change-item.tsx index 0a4312110de..706149a415c 100644 --- a/frontend/app/src/entities/proposed-changes/ui/proposed-change-item.tsx +++ b/frontend/app/src/entities/proposed-changes/ui/proposed-change-item.tsx @@ -1,9 +1,11 @@ import { Icon } from "@iconify-icon/react"; import { Tooltip } from "@infrahub/ui"; +import { ClockIcon } from "lucide-react"; import { ListBoxItem } from "react-aria-components"; import { Link } from "react-router"; import { constructPath } from "@/shared/api/rest/fetch"; +import { Row } from "@/shared/components/container"; import { DateDisplay } from "@/shared/components/display/date-display"; import { Badge } from "@/shared/components/ui/badge"; import { classNames } from "@/shared/utils/common"; @@ -12,6 +14,7 @@ import { CHECK_OBJECT } from "@/entities/diff/domain/model/check"; import { getNodeLabel } from "@/entities/nodes/object/domain/rules/get-node-label"; import { useObjectTableContext } from "@/entities/nodes/object/ui/object-table/object-table-context"; import { useObjectsCount } from "@/entities/nodes/object/ui/queries/get-objects-count.query"; +import { useSort } from "@/entities/nodes/sort/ui/hooks/use-sort"; import type { ProposedChangeItem } from "@/entities/proposed-changes/domain/use-cases/get-proposed-changes"; import { ProposedChangeDiffSummary } from "@/entities/proposed-changes/ui/diff-summary/proposed-change-diff-summary"; import { ProposedChangesActionCell } from "@/entities/proposed-changes/ui/proposed-changes-actions-cell"; @@ -23,9 +26,12 @@ type ProposedChangesItemProps = { }; export const ProposedChangesItem = ({ proposedChange }: ProposedChangesItemProps) => { - const { permission } = useObjectTableContext(); + const { permission, selectedSchema } = useObjectTableContext(); + const { appliedSort } = useSort(selectedSchema); const { node, metadata } = proposedChange; + const showUpdatedAt = appliedSort.some((sort) => sort.field === "node_metadata__updated_at"); + return (
@@ -37,6 +43,7 @@ export const ProposedChangesItem = ({ proposedChange }: ProposedChangesItemProps isDraft={!!node.is_draft?.value} isApproved={!!node.approved_by.edges.length} createdAt={metadata.created_at} + updatedAt={showUpdatedAt ? metadata.updated_at : null} branchName={node.source_branch?.value} /> @@ -65,6 +72,7 @@ type ProposedChangesInfoProps = { isDraft: boolean; isApproved: boolean; createdAt: string | null; + updatedAt?: string | null; branchName?: string; }; @@ -75,6 +83,7 @@ const ProposedChangesInfo = ({ isDraft, isApproved, createdAt, + updatedAt, branchName, }: ProposedChangesInfoProps) => { return ( @@ -102,13 +111,18 @@ const ProposedChangesInfo = ({ {isApproved && approved}
- + {branchName} Opened by {author} - + {updatedAt && ( + <> + Updated + + )} +
); diff --git a/frontend/app/src/entities/proposed-changes/ui/proposed-change-table-filter.tsx b/frontend/app/src/entities/proposed-changes/ui/proposed-change-table-filter.tsx deleted file mode 100644 index 4ed1f62e607..00000000000 --- a/frontend/app/src/entities/proposed-changes/ui/proposed-change-table-filter.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Icon } from "@iconify-icon/react"; -import type { PopoverTriggerProps } from "@radix-ui/react-popover"; -import { useState } from "react"; - -import { cellHeaderStyle, cellsStyle } from "@/shared/components/table/style"; -import { Popover, PopoverContent, PopoverTrigger } from "@/shared/components/ui/popover"; -import { classNames } from "@/shared/utils/common"; - -import { isFieldFiltered } from "@/entities/nodes/filters/domain/rules/is-field-filtered"; -import { useFilters } from "@/entities/nodes/filters/ui/hooks/use-filters"; -import { AttributeFilterForm } from "@/entities/nodes/object/ui/filters/attribute-filter-form"; -import { RelationshipFilterForm } from "@/entities/nodes/object/ui/filters/relationship-filter-form"; -import type { - AttributeSchema, - ModelSchema, - RelationshipSchema, -} from "@/entities/schema/domain/model/schema"; -import { isRelationshipSchema } from "@/entities/schema/domain/rules/is-relationship-schema"; -import { FieldSchemaIcon } from "@/entities/schema/ui/field-schema-icon"; - -export interface TableColumnHeaderProps extends PopoverTriggerProps { - schema: ModelSchema; - columnSchema: AttributeSchema | RelationshipSchema; - customLabel?: string; -} - -export function ProposedChangeTableFilter({ - schema, - columnSchema, - customLabel, - ...props -}: TableColumnHeaderProps) { - const [filters] = useFilters(); - const [showFilters, setShowFilters] = useState(false); - const currentColumnFilters = filters.find((f) => isFieldFiltered(f, columnSchema.name)); - - const closePopover = () => { - setShowFilters(false); - }; - - return ( - - - - - - {customLabel ?? columnSchema.label ?? columnSchema.name} - - - - - -
- Filter by {columnSchema.label ?? columnSchema.name} -
- {isRelationshipSchema(columnSchema) ? ( - - ) : ( - - )} -
-
- ); -} diff --git a/frontend/app/src/entities/proposed-changes/ui/proposed-changes-manager-toolbar.tsx b/frontend/app/src/entities/proposed-changes/ui/proposed-changes-manager-toolbar.tsx index 089fe95d3f8..3c0f29037fc 100644 --- a/frontend/app/src/entities/proposed-changes/ui/proposed-changes-manager-toolbar.tsx +++ b/frontend/app/src/entities/proposed-changes/ui/proposed-changes-manager-toolbar.tsx @@ -8,6 +8,7 @@ import { Row } from "@/shared/components/container"; import { ActiveObjectFilterTags } from "@/entities/nodes/object/ui/filters/active-object-filter-tags"; import { FilterSearchInput } from "@/entities/nodes/object/ui/filters/filter-search-input"; import { ObjectItemsHeader } from "@/entities/nodes/object/ui/object-items-header"; +import { SortPicker } from "@/entities/nodes/sort/ui/sort-picker"; import type { Permission } from "@/entities/permission/domain/model/permission"; import type { ModelSchema } from "@/entities/schema/domain/model/schema"; @@ -29,6 +30,8 @@ export function ProposedChangesManagerToolbar({ + + { - return relationship.name === "created_by"; - }); - const reviewersRelationship = schema.relationships?.find((relationship) => { return relationship.name === "reviewers"; }); @@ -66,31 +62,43 @@ export function ProposedChangesTableFilters({ schema }: ProposedChangesTableHead
{draftAttribute && ( - + )} {stateAttribute && ( - + )} {sourceBranchAttribute && ( - - )} - - {authorRelationship && ( - )} {reviewersRelationship && ( - + )} {approversRelationship && ( - + )}
diff --git a/frontend/app/src/entities/proposed-changes/ui/proposed-changes-table.tsx b/frontend/app/src/entities/proposed-changes/ui/proposed-changes-table.tsx index e0012eb4be5..36449424e0b 100644 --- a/frontend/app/src/entities/proposed-changes/ui/proposed-changes-table.tsx +++ b/frontend/app/src/entities/proposed-changes/ui/proposed-changes-table.tsx @@ -8,7 +8,9 @@ import { classNames } from "@/shared/utils/common"; import { useFilters } from "@/entities/nodes/filters/ui/hooks/use-filters"; import { ObjectTableEmpty } from "@/entities/nodes/object/ui/object-table/object-table-empty"; +import { useSort } from "@/entities/nodes/sort/ui/hooks/use-sort"; import { computeProposedChangeFilters } from "@/entities/proposed-changes/domain/rules/compute-proposed-change-filters"; +import { computeProposedChangeSort } from "@/entities/proposed-changes/domain/rules/compute-proposed-change-sort"; import { ProposedChangesItem } from "@/entities/proposed-changes/ui/proposed-change-item"; import { ProposedChangesTableFilters } from "@/entities/proposed-changes/ui/proposed-changes-table-filters"; import { ProposedChangesTableSkeleton } from "@/entities/proposed-changes/ui/proposed-changes-table-skeleton"; @@ -24,11 +26,13 @@ export function ProposedChangesTable({ schema, className }: ProposedChangesTable const [proposedChangeState] = useQueryState(QSP.PROPOSED_CHANGES_STATE); const [filters] = useFilters(); + const { appliedSort } = useSort(schema); const { data, fetchNextPage, hasNextPage, isPending, isFetchingNextPage } = useGetProposedChanges( { schema, filters: computeProposedChangeFilters({ filters, qsp: proposedChangeState as string }), + sort: computeProposedChangeSort(appliedSort), } ); diff --git a/frontend/app/src/entities/proposed-changes/ui/queries/proposed-changes.query-keys.test.ts b/frontend/app/src/entities/proposed-changes/ui/queries/proposed-changes.query-keys.test.ts index 08391c4f0ad..7d28315d572 100644 --- a/frontend/app/src/entities/proposed-changes/ui/queries/proposed-changes.query-keys.test.ts +++ b/frontend/app/src/entities/proposed-changes/ui/queries/proposed-changes.query-keys.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { Filter } from "@/entities/nodes/filters/domain/model/filter"; +import type { Sort } from "@/entities/nodes/sort/domain/model/sort"; import { PROPOSED_CHANGE_OBJECT } from "@/entities/proposed-changes/domain/model/proposed-change"; import { PROPOSED_CHANGES_THREAD_OBJECT } from "@/entities/proposed-changes/domain/model/proposed-change-thread"; @@ -18,15 +19,35 @@ describe("proposedChangesQueryKeys", () => { it("returns query key for list", () => { // GIVEN const filters: Filter[] = [{ name: "status__value", value: "open" }]; + const sort: Sort[] = [{ field: "node_metadata__created_at", direction: "DESC" }]; const params = { filters, + sort, }; // WHEN const result = proposedChangesQueryKeys.list(params); // THEN - expect(result).toEqual(["objects", PROPOSED_CHANGE_OBJECT, filters]); + expect(result).toEqual(["objects", PROPOSED_CHANGE_OBJECT, filters, sort]); + }); + + it("returns a different list query key per sort", () => { + // GIVEN + const filters: Filter[] = [{ name: "status__value", value: "open" }]; + + // WHEN + const newestFirst = proposedChangesQueryKeys.list({ + filters, + sort: [{ field: "node_metadata__created_at", direction: "DESC" }], + }); + const oldestFirst = proposedChangesQueryKeys.list({ + filters, + sort: [{ field: "node_metadata__created_at", direction: "ASC" }], + }); + + // THEN + expect(newestFirst).not.toEqual(oldestFirst); }); it("returns query key for count", () => { diff --git a/frontend/app/src/entities/proposed-changes/ui/queries/proposed-changes.query-keys.ts b/frontend/app/src/entities/proposed-changes/ui/queries/proposed-changes.query-keys.ts index 31b975cd315..f6d07917822 100644 --- a/frontend/app/src/entities/proposed-changes/ui/queries/proposed-changes.query-keys.ts +++ b/frontend/app/src/entities/proposed-changes/ui/queries/proposed-changes.query-keys.ts @@ -1,16 +1,18 @@ import type { Filter } from "@/entities/nodes/filters/domain/model/filter"; import { objectQueryKeys } from "@/entities/nodes/object/ui/queries/object.query-keys"; +import type { Sort } from "@/entities/nodes/sort/domain/model/sort"; import { PROPOSED_CHANGE_OBJECT } from "@/entities/proposed-changes/domain/model/proposed-change"; import { PROPOSED_CHANGES_THREAD_OBJECT } from "@/entities/proposed-changes/domain/model/proposed-change-thread"; export interface ProposedChangesListKeysParams { filters?: Filter[]; + sort?: Sort[]; } export const proposedChangesQueryKeys = { all: [...objectQueryKeys.all, PROPOSED_CHANGE_OBJECT] as const, - list: ({ filters }: ProposedChangesListKeysParams) => - [...proposedChangesQueryKeys.all, filters] as const, + list: ({ filters, sort }: ProposedChangesListKeysParams) => + [...proposedChangesQueryKeys.all, filters, sort] as const, count: ({ filters }: ProposedChangesListKeysParams) => [...proposedChangesQueryKeys.all, "count", filters] as const, detail: (proposedChangeId: string) => diff --git a/tests/e2e/proposed-changes/test_proposed_changes_ordering.py b/tests/e2e/proposed-changes/test_proposed_changes_ordering.py index 564464d0900..96a839f505d 100644 --- a/tests/e2e/proposed-changes/test_proposed_changes_ordering.py +++ b/tests/e2e/proposed-changes/test_proposed_changes_ordering.py @@ -1,23 +1,32 @@ -"""/proposed-changes list ordering. +"""/proposed-changes list ordering and the Sort picker. -The proposed-changes list (both the Opened and Closed tabs) must be ordered by -creation date, newest first. Regression guard for the list previously coming -back in an arbitrary (node-uuid) order, which buried recently created proposed -changes. +The proposed-changes list (both the Opened and Closed tabs) defaults to creation +date, newest first. Regression guard for the list previously coming back in an +arbitrary (node-uuid) order, which buried recently created proposed changes. -The test owns all of its data: it creates three throwaway branches and one -proposed change per branch through the SDK, so it needs neither the demo -dataset nor the demo-edge repository. +The toolbar carries the generic Sort picker: choosing a field (e.g. the +"Created at" / "Updated at" node metadata) and a direction persists the order in +the `sort` query param. The default order is the absence of a param — the +CoreProposedChange schema defines no order_by, so clearing the custom sort +drops the param and the list falls back to newest created first. -Note on the Closed tab: ordering is by *creation* time, not by when each -proposed change was closed. The test closes the proposed changes in reverse -creation order to prove the list still comes back newest-created-first (the -most recently closed one is not floated to the top). +The filter bar's field chips open the object table's column-header menu, so a +chip offers both sorting and filtering for its field from one popover. + +The tests own all of their data: three throwaway branches and one proposed +change per branch through the SDK, so they need neither the demo dataset nor the +demo-edge repository. + +Note on the default order: it is by *creation* time, never by when a proposed +change was closed. The tests close proposed changes in reverse creation order, +which keeps the default order stable while making the update order the exact +reverse — so an assertion on one cannot pass under the other. """ from __future__ import annotations import contextlib +import re from typing import TYPE_CHECKING import pytest @@ -35,11 +44,16 @@ async def _rendered_pc_order(page: Page) -> list[str]: - """Return the proposed-change ids in the order they are rendered in the list.""" + """Return the proposed-change ids in the order they are rendered in the list. + + Changing the order restarts the query, which empties the list until the first + page of the new order arrives — so wait for a row before reading, otherwise + the scrape races the refetch and comes back empty. + """ listbox = page.get_by_role("listbox") - hrefs = await listbox.locator('a[href*="/proposed-changes/"]').evaluate_all( - "els => els.map((e) => e.getAttribute('href'))" - ) + rows = listbox.locator('a[href*="/proposed-changes/"]') + await expect(rows.first).to_be_visible() + hrefs = await rows.evaluate_all("els => els.map((e) => e.getAttribute('href'))") order: list[str] = [] for href in hrefs: pc_id = href.split("/proposed-changes/", 1)[1].split("?", 1)[0].split("/", 1)[0] @@ -48,6 +62,22 @@ async def _rendered_pc_order(page: Page) -> list[str]: return order +async def _open_sort_picker(page: Page) -> None: + """Open the Sort picker popover (the trigger carries a count badge once a sort is applied).""" + await page.get_by_role("button", name=re.compile(r"^Sort( \d+)?$")).click() + + +async def _add_sort(page: Page, field: str, direction: str) -> None: + """Pick a sort from scratch: choose a field, then its direction in the submenu. + + Ends with Escape so the popover doesn't cover the first rows of the list. + """ + await _open_sort_picker(page) + await page.get_by_role("menuitem", name=field).click() + await page.get_by_role("menuitem", name=direction).click() + await page.keyboard.press("Escape") + + class TestProposedChangesOrdering: @pytest.fixture async def proposed_changes( @@ -97,3 +127,88 @@ async def test_list_is_ordered_newest_first(self, admin_page: Page, proposed_cha await expect(admin_page.locator(f'a[href*="/proposed-changes/{newest.id}"]')).to_be_visible() closed_order = await _rendered_pc_order(admin_page) assert [pc_id for pc_id in closed_order if pc_id in expected] == expected + + async def test_sort_picker_flips_the_creation_order( + self, admin_page: Page, proposed_changes: list[InfrahubNode] + ) -> None: + oldest, middle, newest = proposed_changes + newest_first = [newest.id, middle.id, oldest.id] + + await admin_page.goto("/proposed-changes") + await expect(admin_page.locator(f'a[href*="/proposed-changes/{newest.id}"]')).to_be_visible() + + await _add_sort(admin_page, "Created at", "Ascending") + + await expect(admin_page).to_have_url(re.compile(r"sort=node_metadata__created_at__asc")) + oldest_order = await _rendered_pc_order(admin_page) + assert [pc_id for pc_id in oldest_order if pc_id in newest_first] == list(reversed(newest_first)) + + # The default order is the absence of a param, so clearing the sort drops it entirely. + await _open_sort_picker(admin_page) + await admin_page.get_by_role("button", name="Clear sort").click() + await admin_page.keyboard.press("Escape") + + await expect(admin_page).not_to_have_url(re.compile(r"sort=")) + restored_order = await _rendered_pc_order(admin_page) + assert [pc_id for pc_id in restored_order if pc_id in newest_first] == newest_first + + async def test_sort_picker_orders_by_update_date( + self, admin_page: Page, proposed_changes: list[InfrahubNode] + ) -> None: + oldest, middle, newest = proposed_changes + newest_first = [newest.id, middle.id, oldest.id] + + # Closing in reverse creation order makes the update order the exact reverse of the + # creation order, so neither assertion below can pass under the other ordering. + for pc in (newest, middle, oldest): + pc.state.value = "closed" + await pc.save() + + await admin_page.goto("/proposed-changes?pr_state=closed") + await expect(admin_page.locator(f'a[href*="/proposed-changes/{newest.id}"]')).to_be_visible() + + listbox = admin_page.get_by_role("listbox") + await expect(listbox.get_by_text("Updated")).to_have_count(0) + + await _add_sort(admin_page, "Updated at", "Descending") + + await expect(listbox.get_by_text("Updated").first).to_be_visible() + + await expect(admin_page).to_have_url(re.compile(r"sort=node_metadata__updated_at__desc")) + updated_order = await _rendered_pc_order(admin_page) + assert [pc_id for pc_id in updated_order if pc_id in newest_first] == list(reversed(newest_first)) + + # Flip the direction from the applied-sort row inside the picker. + await _open_sort_picker(admin_page) + await admin_page.get_by_role("button", name=re.compile(r"Sort direction")).click() + await admin_page.get_by_role("option", name="Ascending").click() + await admin_page.keyboard.press("Escape") + + await expect(admin_page).to_have_url(re.compile(r"sort=node_metadata__updated_at__asc")) + least_updated_order = await _rendered_pc_order(admin_page) + assert [pc_id for pc_id in least_updated_order if pc_id in newest_first] == newest_first + + async def test_field_chip_sorts_and_filters(self, admin_page: Page, proposed_changes: list[InfrahubNode]) -> None: + oldest, middle, newest = proposed_changes + newest_first = [newest.id, middle.id, oldest.id] + + await admin_page.goto("/proposed-changes") + await expect(admin_page.locator(f'a[href*="/proposed-changes/{newest.id}"]')).to_be_visible() + + # Branch names embed their creation index, so their ascending order is the creation order. + await admin_page.get_by_role("button", name="Source Branch").click() + await admin_page.get_by_role("menuitem", name="Sort ascending").click() + + await expect(admin_page).to_have_url(re.compile(r"sort=source_branch__value__asc")) + branch_order = await _rendered_pc_order(admin_page) + assert [pc_id for pc_id in branch_order if pc_id in newest_first] == list(reversed(newest_first)) + + # The same chip also filters its field. + await admin_page.get_by_role("button", name="Source Branch").click() + await admin_page.get_by_role("menuitem", name="Filter").click() + filter_form = admin_page.get_by_test_id("attribute-filter-form") + await filter_form.get_by_role("textbox").fill(str(newest.source_branch.value)) + await filter_form.get_by_role("button", name="Apply").click() + + await expect(admin_page.locator(f'a[href*="/proposed-changes/{oldest.id}"]')).not_to_be_visible() + await expect(admin_page.locator(f'a[href*="/proposed-changes/{newest.id}"]')).to_be_visible() From 34104714eba9bcdf672013e247d7260a18a1386d Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Sun, 2 Aug 2026 17:35:39 +0200 Subject: [PATCH 71/75] feat(schema): report the schema fields the load endpoint does not apply (#10095) * feat(schema): report the schema fields the load endpoint does not apply Loading a schema dropped every field the user may not set without saying so, so a misspelled key produced a schema quietly different from the one the author wrote. Classify them instead: a name the contract knows at that location but the user may not set is reported as a warning and still dropped, keeping the read-back, edit, re-load round trip working; any other name is rejected. The generator gains a table of the non-settable fields of each write class, emitted into the SDK as schema/generated/contract.py. It is derived from two diffs: the read variant of a class against its write variant, and the internal pydantic counterpart of a value model against its generated write model. The second diff covers the parameters, choice, computed-attribute and extension models, which are declared in the generator rather than derived from internal.py and so omit the id/state bookkeeping every internal schema model carries; those names appear in any schema dumped from the internal models. SchemaLoadAPI validates through a wrap validator so the warnings, visible only on the raw payload, can be carried on the instance and merged into the response alongside gather_warnings(). They ride the existing deprecation warning type, which an SDK older than this change can still parse. Repository imports report them too, and fail on an unknown field. Two shapes that previously loaded now fail: a misspelled field name, and attribute parameters belonging to a different attribute kind, which configured nothing on the kind they were set on. The published request schema is unchanged -- the load endpoint still validates through InfrahubSchemaWrite alone, so no read-only field is advertised as settable and schema/openapi.json is byte-identical. Also return the collected warnings when the submitted schema matches the one already loaded; the early return dropped them, so a warning went unreported on every load after the first. Co-Authored-By: Claude Opus 5 (1M context) * ci: drop possessive "SDK's" flagged by the release-notes vale gate The validate-release-notes-style job runs vale over ./changelog, where Infrahub.spelling rejects the possessive. Use the attributive form the existing fragments already use. Co-Authored-By: Claude Opus 5 (1M context) * ci: name nested read-only fields relative to their owner backend-tests-integration failed on test_check_schema_02, which submits a full internal dump and asserted the check returned no warnings. The submitted dump legitimately carries the fields Infrahub owns, so the assertion had to change -- but the warnings it exposed showed a defect: a nested finding carried the bare key, so `parameters.id` was reported as `id` against the owning attribute, where `id` is in fact settable, and collided with the `id` from another block once grouped by name. Bump python_sdk to qualify the name with the fields walked since the last kind or element, and assert the resulting warnings in the integration test. Co-Authored-By: Claude Opus 5 (1M context) * test: parametrize the read-only table check with a dataclass The case data was held in a dict keyed by family name, parametrized over the keys and looked up inside the test -- a mix of the two styles. Use the dataclass pattern the testing rules call for, keeping the pytest ids unchanged. Co-Authored-By: Claude Opus 5 (1M context) * docs: describe the write contract against the last released version The write-contract work is not released yet, so its changelog and deprecation guide were describing a change history relative to an unreleased intermediate state rather than to v1.10.6. Two of the resulting claims were wrong: unknown fields and a missing `version` were already rejected there, since the load endpoint validated through a model with `extra="forbid"`. Fold this branch's fragment and the intermediate read-only fragment into the one pending `changed` fragment, so 1.11 publishes a single accurate story, and restate the guide's "what changed" against v1.10.6: constrained values and misplaced attribute `parameters` are the new rejections, read-only fields are newly reported as warnings, and an unrecognized field is rejected as before with only its wording changed. The unchanged-schema warnings fragment stays: that early return drops warnings in v1.10.6, so it is a genuine fix against released behaviour. Co-Authored-By: Claude Opus 5 (1M context) * refactor(schema): resolve the read-only table in the generator The generated table held each class's own read-only fields, leaving consumers to union them across the model's MRO. Resolve the parent chain here so each entry is complete and a lookup needs only the class name, and bump python_sdk for the matching consumer change. Co-Authored-By: Claude Opus 5 (1M context) * test: assert the read-only table resolves every inherited field The offline validator looks a class up by name alone, so a generator that emitted only a class's own read-only fields would let an inherited one fall through as an unknown field, turning a value that must be tolerated on a round trip into a hard error. Nothing guarded that, since the tables asserted per family only cover the five classes paired with a read model. Co-Authored-By: Claude Opus 5 (1M context) * chore(sdk): point python_sdk at merged infrahub-develop The companion SDK pull request is merged, so the submodule tracks the merge commit on infrahub-develop instead of the feature branch. The tree is unchanged: the merge carries no work beyond what the pointer already referenced. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- backend/infrahub/api/schema.py | 59 +++++- backend/infrahub/git/integrator.py | 19 +- .../tests/functional/api/test_load_schema.py | 120 +++++++---- .../test_migration_hierarchy_change.py | 12 +- .../unit/api/test_schema_load_contract.py | 191 +++++++++++++----- .../core/schema/test_generated_visibility.py | 76 +++++++ .../+infp-234-schema-load-read-only.fixed.md | 1 - changelog/+infp-234.changed.md | 20 +- .../+schema-load-unchanged-warnings.fixed.md | 1 + ...0-generated-user-facing-schema-contract.md | 51 ++++- dev/knowledge/backend/schema-definitions.md | 37 +++- .../schema-load-write-contract.mdx | 101 ++++++--- python_sdk | 2 +- tasks/backend.py | 132 +++++++++++- 14 files changed, 682 insertions(+), 140 deletions(-) delete mode 100644 changelog/+infp-234-schema-load-read-only.fixed.md create mode 100644 changelog/+schema-load-unchanged-warnings.fixed.md diff --git a/backend/infrahub/api/schema.py b/backend/infrahub/api/schema.py index acd8d18a372..6089918bb7d 100644 --- a/backend/infrahub/api/schema.py +++ b/backend/infrahub/api/schema.py @@ -11,11 +11,13 @@ TemplateSchemaRead, ) from infrahub_sdk.schema.generated.write import InfrahubSchemaWrite +from infrahub_sdk.schema.validate import SchemaValidationWarningDetail from infrahub_sdk.schema.validate import validate_schema as validate_write_schema from pydantic import ( BaseModel, Field, PrivateAttr, + ValidatorFunctionWrapHandler, computed_field, create_model, model_validator, @@ -45,6 +47,8 @@ ProfileSchema, SchemaRoot, SchemaWarning, + SchemaWarningKind, + SchemaWarningType, TemplateSchema, ) from infrahub.core.schema.constants import SchemaNamespace # noqa: TC001 @@ -99,18 +103,51 @@ class SchemaReadAPI(BaseModel): namespaces: list[SchemaNamespace] = Field(default_factory=list) +def read_only_field_warnings(details: list[SchemaValidationWarningDetail]) -> list[SchemaWarning]: + """Aggregate read-only field findings into one warning per field name. + + A payload read back from the schema API repeats the same read-only field on every node and + attribute it contains, so grouping by field name keeps the response proportional to the number + of distinct offending fields rather than to the size of the schema. + """ + grouped: dict[str, list[SchemaWarningKind]] = {} + for detail in details: + kinds = grouped.setdefault(detail.name, []) + if detail.kind is None: + continue + kind = SchemaWarningKind(kind=detail.kind, field=detail.element) + if kind not in kinds: + kinds.append(kind) + + return [ + SchemaWarning( + type=SchemaWarningType.DEPRECATION, + kinds=kinds, + message=f"'{name}' is a read-only field, the submitted value is ignored", + ) + for name, kinds in grouped.items() + ] + + class SchemaLoadAPI(InfrahubSchemaWrite): _internal_schema: SchemaRoot = PrivateAttr() + _contract_warnings: list[SchemaWarning] = PrivateAttr(default_factory=list) - @model_validator(mode="before") + @model_validator(mode="wrap") @classmethod - def validate_write_contract(cls, data: Any) -> Any: + def validate_write_contract(cls, data: Any, handler: ValidatorFunctionWrapHandler) -> Self: + # Wrapped rather than run before validation so the warnings, which are only visible on the + # raw payload, can be carried on the instance the handler returns. + result = validate_write_schema(schema=data) if isinstance(data, dict) else None # Raising here turns the field-level messages into a single request-validation error. - if isinstance(data, dict): - result = validate_write_schema(schema=data) - if not result.valid: - raise ValueError("; ".join(result.messages)) - return data + if result is not None and not result.valid: + raise ValueError("; ".join(result.messages)) + + instance: Self = handler(data) + + if result is not None: + instance._contract_warnings = read_only_field_warnings(details=result.warnings) + return instance @model_validator(mode="after") def build_internal_schema(self) -> Self: @@ -122,6 +159,10 @@ def build_internal_schema(self) -> Self: def internal_schema(self) -> SchemaRoot: return self._internal_schema + @property + def contract_warnings(self) -> list[SchemaWarning]: + return self._contract_warnings + class SchemasLoadAPI(BaseModel): schemas: list[SchemaLoadAPI] @@ -367,6 +408,7 @@ async def load_schema( candidate_schemas.append(internal_schema) errors += internal_schema.validate_reserved_names() warnings += internal_schema.gather_warnings() + warnings += schema.contract_warnings if errors: raise SchemaNotValidError(message=", ".join(errors)) @@ -380,7 +422,7 @@ async def load_schema( ) if not result.diff.all: - return SchemaUpdate(hash=original_hash, previous_hash=original_hash, diff=result.diff) + return SchemaUpdate(hash=original_hash, previous_hash=original_hash, diff=result.diff, warnings=warnings) # ---------------------------------------------------------- # Validate if the new schema is valid with the content of the database @@ -460,6 +502,7 @@ async def check_schema( candidate_schemas.append(internal_schema) errors += internal_schema.validate_reserved_names() warnings += internal_schema.gather_warnings() + warnings += schema.contract_warnings if errors: raise SchemaNotValidError(message=", ".join(errors)) diff --git a/backend/infrahub/git/integrator.py b/backend/infrahub/git/integrator.py index 4004b441352..b244a82d42c 100644 --- a/backend/infrahub/git/integrator.py +++ b/backend/infrahub/git/integrator.py @@ -33,6 +33,7 @@ InfrahubRepositoryConfig, InfrahubWatchConfig, ) +from infrahub_sdk.schema.validate import validate_schema as validate_write_schema from infrahub_sdk.spec.menu import MenuFile from infrahub_sdk.spec.object import ObjectFile from infrahub_sdk.template import Jinja2Template @@ -865,14 +866,16 @@ async def import_schema_files(self, branch_name: str, commit: str, config_file: # Valid data format of content for schema_file in schemas_data: - try: - self.sdk.schema.validate(schema_file.content) - except PydanticValidationError as exc: - log.error(f"Schema not valid, found '{len(exc.errors())}' error(s) in {schema_file.identifier} : {exc}") - raise ValidationError( - identifier=str(self.id), - message=f"Schema not valid, found '{len(exc.errors())}' error(s) in {schema_file.identifier} : {exc}", - ) from exc + result = validate_write_schema(schema=schema_file.content or {}) + for warning in result.warnings: + log.warning(f"{schema_file.identifier}: {warning.message}") + if not result.valid: + message = ( + f"Schema not valid, found '{len(result.errors)}' error(s) in " + f"{schema_file.identifier} : {'; '.join(result.messages)}" + ) + log.error(message) + raise ValidationError(identifier=str(self.id), message=message) response = await self.sdk.schema.load( schemas=[item.content for item in schemas_data], branch=branch_name, wait_until_converged=True diff --git a/backend/tests/functional/api/test_load_schema.py b/backend/tests/functional/api/test_load_schema.py index 9da7e5c771f..123c9d89579 100644 --- a/backend/tests/functional/api/test_load_schema.py +++ b/backend/tests/functional/api/test_load_schema.py @@ -601,13 +601,13 @@ async def test_remove_optional_text_field_value( assert schema.parent == "TestLocation" assert schema.children == "TestLocation" - async def test_schema_load_tolerates_non_write_and_unknown_fields( + async def test_schema_load_accepts_read_level_field_and_warns( self, initial_dataset: str, test_client: InfrahubTestClient, api_admin_token: str, ) -> None: - """Accept a payload carrying a read-level field plus an unknown field.""" + """Accept a payload carrying a read-level field and report it as a warning.""" payload = { "schemas": [ { @@ -616,14 +616,7 @@ async def test_schema_load_tolerates_non_write_and_unknown_fields( { "name": "Device", "namespace": "Test", - "attributes": [ - { - "name": "name", - "kind": "Text", - "inherited": True, - "not_a_real_field": "value", - } - ], + "attributes": [{"name": "name", "kind": "Text", "inherited": True}], } ], } @@ -637,6 +630,47 @@ async def test_schema_load_tolerates_non_write_and_unknown_fields( ) assert response.status_code == 200 + warnings = response.json()["warnings"] + assert [warning["message"] for warning in warnings] == [ + "'inherited' is a read-only field, the submitted value is ignored" + ] + assert warnings[0]["kinds"] == [{"kind": "TestDevice", "field": "name"}] + + async def test_schema_load_rejects_unknown_field( + self, + initial_dataset: str, + test_client: InfrahubTestClient, + api_admin_token: str, + ) -> None: + """Reject a payload carrying a field the contract does not know, naming the field.""" + payload = { + "schemas": [ + { + "version": "1.0", + "nodes": [ + { + "name": "Device", + "namespace": "Test", + "attributes": [{"name": "name", "kind": "Text", "not_a_real_field": "value"}], + } + ], + } + ] + } + + response = await test_client.post( + "/api/schema/load", + json=payload, + headers={"X-INFRAHUB-KEY": api_admin_token}, + ) + + assert response.status_code == 422 + messages = [item["msg"] for item in response.json()["detail"]] + assert len(messages) == 1, messages + assert ( + "nodes[0].attributes[0].not_a_real_field: Unknown field, it is not part of the schema " + "(received: 'value')" in messages[0] + ), messages[0] async def test_schema_load_rejects_out_of_enum_attribute_kind( self, @@ -675,15 +709,15 @@ async def test_schema_load_rejects_out_of_enum_attribute_kind( assert "Input tag 'NotARealKind' found using 'kind' does not match any of the expected tags" in message, message assert "(received: {'name': 'name', 'kind': 'NotARealKind'})" in message, message - async def test_schema_load_tolerates_non_write_and_unknown_fields_in_extensions( + async def test_schema_load_accepts_read_level_field_in_extensions( self, initial_dataset: str, test_client: InfrahubTestClient, api_admin_token: str, ) -> None: - """Accept an extension attribute carrying a read-level field and an unknown field.""" + """Accept an extension attribute carrying a read-level field, and report it as a warning.""" # Define a fresh node (no existing instances) so the extension is applicable, then extend it - # with an optional attribute carrying a read-level field and an unknown field. + # with an optional attribute carrying a read-level field. base = { "schemas": [ { @@ -709,15 +743,7 @@ async def test_schema_load_tolerates_non_write_and_unknown_fields_in_extensions( "nodes": [ { "kind": "TestGadget", - "attributes": [ - { - "name": "extra", - "kind": "Text", - "optional": True, - "inherited": True, - "not_a_real_field": "value", - } - ], + "attributes": [{"name": "extra", "kind": "Text", "optional": True, "inherited": True}], } ] }, @@ -732,6 +758,9 @@ async def test_schema_load_tolerates_non_write_and_unknown_fields_in_extensions( ) assert response.status_code == 200 + assert [warning["message"] for warning in response.json()["warnings"]] == [ + "'inherited' is a read-only field, the submitted value is ignored" + ] async def test_schema_load_rejects_out_of_enum_relationship_cardinality( self, @@ -884,9 +913,9 @@ async def test_write_contract_parity_sdk_offline_vs_load_endpoint( ) -> None: """The same payload yields the same verdict offline (SDK) and via POST /api/schema/load. - A valid payload passes both; a payload carrying only non-write/unknown fields is tolerated by - both (the extra fields are dropped); an out-of-enum value is rejected by both, and the invalid - value the SDK names offline appears in the server's rejection response. + A valid payload passes both; a payload carrying a read-level field is accepted by both with + the same warning; an unknown field and an out-of-enum value are rejected by both, and the + offending value the SDK names offline appears in the server's rejection response. """ valid_schema_root = { "version": "1.0", @@ -904,14 +933,17 @@ async def test_write_contract_parity_sdk_offline_vs_load_endpoint( { "name": "Device", "namespace": "Test", - "attributes": [ - { - "name": "name", - "kind": "Text", - "inherited": True, - "not_a_real_field": "value", - } - ], + "attributes": [{"name": "name", "kind": "Text", "inherited": True}], + } + ], + } + unknown_field_schema_root = { + "version": "1.0", + "nodes": [ + { + "name": "Device", + "namespace": "Test", + "attributes": [{"name": "name", "kind": "Text", "not_a_real_field": "value"}], } ], } @@ -936,15 +968,35 @@ async def test_write_contract_parity_sdk_offline_vs_load_endpoint( ) assert response_valid.status_code == 200 - # Non-write/unknown fields: tolerated offline and accepted by the server (the fields are dropped). + # Read-level field: accepted by both, and both name it as a warning rather than an error. offline_tolerated = validate_schema(schema=tolerated_schema_root) assert offline_tolerated.valid is True + assert [warning.name for warning in offline_tolerated.warnings] == ["inherited"] response_tolerated = await test_client.post( "/api/schema/load", json={"schemas": [tolerated_schema_root]}, headers={"X-INFRAHUB-KEY": api_admin_token}, ) assert response_tolerated.status_code == 200 + assert [warning["message"] for warning in response_tolerated.json()["warnings"]] == [ + "'inherited' is a read-only field, the submitted value is ignored" + ] + + # Unknown field: SDK offline verdict is "invalid" and the server rejects it (422) with the + # same field-level message. + offline_unknown = validate_schema(schema=unknown_field_schema_root) + assert offline_unknown.valid is False + assert len(offline_unknown.messages) == 1, offline_unknown.messages + unknown_message = offline_unknown.messages[0] + response_unknown = await test_client.post( + "/api/schema/load", + json={"schemas": [unknown_field_schema_root]}, + headers={"X-INFRAHUB-KEY": api_admin_token}, + ) + assert response_unknown.status_code == 422 + unknown_server_messages = [item["msg"] for item in response_unknown.json()["detail"]] + assert len(unknown_server_messages) == 1, unknown_server_messages + assert unknown_message in unknown_server_messages[0], (unknown_message, unknown_server_messages) # Out-of-enum value: SDK offline verdict is "invalid" and the server rejects it (422), # naming the invalid value the SDK named offline. diff --git a/backend/tests/integration/schema_lifecycle/test_migration_hierarchy_change.py b/backend/tests/integration/schema_lifecycle/test_migration_hierarchy_change.py index 434f240a2c3..aa89fdb3f55 100644 --- a/backend/tests/integration/schema_lifecycle/test_migration_hierarchy_change.py +++ b/backend/tests/integration/schema_lifecycle/test_migration_hierarchy_change.py @@ -124,6 +124,17 @@ async def test_check_schema_02( schemas=[location_schema_02.model_dump(mode="json")], branch=branch_1.name ) assert success + # Submitting a full internal dump carries the fields Infrahub owns and derives itself, so + # the check reports each of them rather than dropping the values silently. + assert [warning["message"] for warning in response.pop("warnings")] == [ + "'hierarchy' is a read-only field, the submitted value is ignored", + "'used_by' is a read-only field, the submitted value is ignored", + "'inherited' is a read-only field, the submitted value is ignored", + "'parameters.id' is a read-only field, the submitted value is ignored", + "'parameters.state' is a read-only field, the submitted value is ignored", + "'extensions.id' is a read-only field, the submitted value is ignored", + "'extensions.state' is a read-only field, the submitted value is ignored", + ] assert response == { "diff": { "added": {"LocationMetro": {"added": {}, "changed": {}, "removed": {}}}, @@ -155,7 +166,6 @@ async def test_check_schema_02( }, }, }, - "warnings": [], } async def test_load_schema_02( diff --git a/backend/tests/unit/api/test_schema_load_contract.py b/backend/tests/unit/api/test_schema_load_contract.py index b1edbee0306..1883093da56 100644 --- a/backend/tests/unit/api/test_schema_load_contract.py +++ b/backend/tests/unit/api/test_schema_load_contract.py @@ -5,10 +5,12 @@ import pytest from infrahub_sdk.schema import validate_schema +from infrahub_sdk.schema.generated.contract import READ_ONLY_FIELDS +from infrahub_sdk.schema.generated.write import InfrahubSchemaWrite -from infrahub.api.schema import SchemaLoadAPI +from infrahub.api.schema import SchemaLoadAPI, SchemaReadAPI from infrahub.core.constants import ComputedAttributeKind, HashableModelState -from infrahub.core.schema import SchemaRoot +from infrahub.core.schema import SchemaRoot, SchemaWarningType from tests.helpers.schema.snow import SNOW_INCIDENT, SNOW_REQUEST, SNOW_TASK @@ -26,7 +28,7 @@ class LoadContractCase: LOAD_CONTRACT_CASES = [ - LoadContractCase(name="full-internal-dump-tolerated", use_full_dump=True, accepted=True), + LoadContractCase(name="full-internal-dump-accepted", use_full_dump=True, accepted=True), LoadContractCase( name="minimal-write-payload", payload={ @@ -36,25 +38,53 @@ class LoadContractCase: accepted=True, ), LoadContractCase( - name="non-write-field-on-extension-tolerated", + name="unknown-field-on-extension-rejected", payload={"version": "1.0", "extensions": {"nodes": [{"kind": "BuiltinTag", "namespace": "Dropped"}]}}, - accepted=True, + accepted=False, ), LoadContractCase( - name="unknown-field-on-node-tolerated", + name="unknown-field-on-node-rejected", payload={"version": "1.0", "nodes": [{"namespace": "Test", "name": "Widget", "not_a_field": 1}]}, + accepted=False, + ), + LoadContractCase( + name="read-only-field-on-attribute-accepted", + payload={ + "version": "1.0", + "nodes": [ + { + "namespace": "Test", + "name": "Widget", + "attributes": [{"name": "field_one", "kind": "Text", "inherited": True, "state": "present"}], + } + ], + }, accepted=True, ), LoadContractCase( - name="non-write-and-unknown-fields-on-attribute-tolerated", + name="unknown-field-on-attribute-rejected", payload={ "version": "1.0", "nodes": [ { "namespace": "Test", "name": "Widget", - "attributes": [ - {"name": "field_one", "kind": "Text", "not_a_field": 1, "inherited": True, "state": "present"} + "attributes": [{"name": "field_one", "kind": "Text", "not_a_field": 1}], + } + ], + }, + accepted=False, + ), + LoadContractCase( + name="read-only-field-on-relationship-accepted", + payload={ + "version": "1.0", + "nodes": [ + { + "namespace": "Test", + "name": "Widget", + "relationships": [ + {"name": "gadgets", "peer": "TestGadget", "cardinality": "many", "inherited": True} ], } ], @@ -62,7 +92,7 @@ class LoadContractCase: accepted=True, ), LoadContractCase( - name="non-write-and-unknown-fields-on-relationship-tolerated", + name="unknown-field-on-relationship-rejected", payload={ "version": "1.0", "nodes": [ @@ -70,18 +100,12 @@ class LoadContractCase: "namespace": "Test", "name": "Widget", "relationships": [ - { - "name": "gadgets", - "peer": "TestGadget", - "cardinality": "many", - "not_a_field": 1, - "inherited": True, - } + {"name": "gadgets", "peer": "TestGadget", "cardinality": "many", "not_a_field": 1} ], } ], }, - accepted=True, + accepted=False, ), LoadContractCase( name="computed-attribute-accepted", @@ -100,7 +124,6 @@ class LoadContractCase: "computed_attribute": { "kind": "Jinja2", "jinja2_template": "{{ name__value }}", - "not_a_field": 1, }, } ], @@ -109,6 +132,30 @@ class LoadContractCase: }, accepted=True, ), + LoadContractCase( + name="unknown-field-on-computed-attribute-rejected", + payload={ + "version": "1.0", + "nodes": [ + { + "namespace": "Test", + "name": "Widget", + "attributes": [ + { + "name": "field_one", + "kind": "Text", + "computed_attribute": { + "kind": "Jinja2", + "jinja2_template": "{{ name__value }}", + "not_a_field": 1, + }, + } + ], + } + ], + }, + accepted=False, + ), LoadContractCase( name="computed-attribute-unknown-kind-rejected", payload={ @@ -164,49 +211,98 @@ def test_offline_validation_matches_load_contract_verdict(case: LoadContractCase assert result.valid is case.accepted, result.messages -def test_non_write_fields_do_not_reach_the_internal_schema() -> None: - # Non-write and unknown keys are tolerated at every nesting level, but a submitted value must - # never win over the server-owned one: ``inherited`` stays False even though the payload set it. - payload = { +def _read_only_payload() -> dict[str, Any]: + return { "version": "1.0", "nodes": [ { "namespace": "Test", "name": "Widget", - "not_a_field": 1, - "attributes": [ - {"name": "field_one", "kind": "Text", "inherited": True, "state": "absent", "not_a_field": 1} - ], - "relationships": [ - { - "name": "gadgets", - "peer": "TestGadget", - "cardinality": "many", - "inherited": True, - "not_a_field": 1, - } - ], + "hierarchy": "TestThing", + "attributes": [{"name": "field_one", "kind": "Text", "inherited": True, "state": "absent"}], + "relationships": [{"name": "gadgets", "peer": "TestGadget", "cardinality": "many", "inherited": True}], } ], - "extensions": {"nodes": [{"kind": "BuiltinTag", "namespace": "Dropped"}]}, } - loaded = SchemaLoadAPI.model_validate(payload) + +def test_read_only_fields_do_not_reach_the_internal_schema() -> None: + # A read-only field is accepted so a schema read back from Infrahub still loads, but the + # submitted value must never win over the server-owned one: ``inherited`` stays False. + loaded = SchemaLoadAPI.model_validate(_read_only_payload()) node = loaded.internal_schema.nodes[0] assert node.attributes[0].inherited is False assert node.relationships[0].inherited is False # ``state`` is settable, so the submitted value must survive where ``inherited`` did not assert node.attributes[0].state is HashableModelState.ABSENT - assert "not_a_field" not in loaded.model_dump()["nodes"][0] - assert "not_a_field" not in loaded.model_dump()["nodes"][0]["attributes"][0] - assert "not_a_field" not in loaded.model_dump()["nodes"][0]["relationships"][0] - assert "namespace" not in loaded.model_dump()["extensions"]["nodes"][0] + assert loaded.internal_schema.nodes[0].hierarchy is None + + +def test_read_only_fields_are_reported_as_warnings_grouped_by_field() -> None: + # The load response carries these back to the user, so each distinct read-only field is one + # warning naming every kind and element that set it, not one warning per occurrence. + loaded = SchemaLoadAPI.model_validate(_read_only_payload()) + + assert [warning.type for warning in loaded.contract_warnings] == [ + SchemaWarningType.DEPRECATION, + SchemaWarningType.DEPRECATION, + ] + reported = { + warning.message: sorted((kind.kind, kind.field) for kind in warning.kinds) + for warning in loaded.contract_warnings + } + assert reported == { + "'hierarchy' is a read-only field, the submitted value is ignored": [("TestWidget", None)], + "'inherited' is a read-only field, the submitted value is ignored": [ + ("TestWidget", "field_one"), + ("TestWidget", "gadgets"), + ], + } + + +def test_a_payload_without_read_only_fields_reports_no_warning() -> None: + payload = { + "version": "1.0", + "nodes": [{"namespace": "Test", "name": "Widget", "attributes": [{"name": "field_one", "kind": "Text"}]}], + } + + assert SchemaLoadAPI.model_validate(payload).contract_warnings == [] + + +def test_root_read_only_fields_cover_the_read_api_response() -> None: + # A raw GET /api/schema body resubmitted to the load endpoint must warn rather than fail, so + # every top-level key the read response adds over the write root is classified as read-only. + read_only = READ_ONLY_FIELDS[InfrahubSchemaWrite.__name__] + + assert set(SchemaReadAPI.model_fields) - set(InfrahubSchemaWrite.model_fields) == read_only + + +def test_unknown_field_is_rejected_naming_the_field() -> None: + payload = { + "version": "1.0", + "nodes": [ + { + "namespace": "Test", + "name": "Widget", + "attributes": [{"name": "field_one", "kind": "Text", "inheritd": True}], + } + ], + } + + with pytest.raises( + ValueError, + match=r"nodes\[0\]\.attributes\[0\]\.inheritd: Unknown field, it is not part of the schema " + r"\(received: True\)", + ): + SchemaLoadAPI.model_validate(payload) def test_computed_attribute_survives_its_discriminated_union() -> None: # Each attribute kind and computed-attribute kind is a separate variant of a discriminated - # union; dropping non-write keys must not collapse a variant onto the wrong one. + # union; dropping the fields of a sibling variant must not collapse a variant onto the wrong + # one. ``transform`` belongs to the TransformPython variant, so setting it on a Jinja2 one is + # tolerated with a warning rather than switching the variant. payload = { "version": "1.0", "nodes": [ @@ -222,7 +318,7 @@ def test_computed_attribute_survives_its_discriminated_union() -> None: "computed_attribute": { "kind": "Jinja2", "jinja2_template": "{{ name__value }}", - "not_a_field": 1, + "transform": "my_transform", }, } ], @@ -237,7 +333,12 @@ def test_computed_attribute_survives_its_discriminated_union() -> None: assert computed.kind is ComputedAttributeKind.JINJA2 assert computed.jinja2_template == "{{ name__value }}" assert computed.transform is None - assert "not_a_field" not in loaded.model_dump()["nodes"][0]["attributes"][0]["computed_attribute"] + assert "transform" not in loaded.model_dump()["nodes"][0]["attributes"][0]["computed_attribute"] + # Named relative to its owner: `transform` is settable on a TransformPython computed + # attribute, so the warning has to say which block the ignored value sat in. + assert [warning.message for warning in loaded.contract_warnings] == [ + "'computed_attribute.transform' is a read-only field, the submitted value is ignored" + ] def test_out_of_enum_attribute_kind_is_rejected_naming_the_value() -> None: diff --git a/backend/tests/unit/core/schema/test_generated_visibility.py b/backend/tests/unit/core/schema/test_generated_visibility.py index b728fb50f33..2c6265c8ad2 100644 --- a/backend/tests/unit/core/schema/test_generated_visibility.py +++ b/backend/tests/unit/core/schema/test_generated_visibility.py @@ -3,9 +3,13 @@ import enum import types import typing +from dataclasses import dataclass import pytest +from infrahub_sdk.schema.generated import read as sdk_read from infrahub_sdk.schema.generated import write as sdk_write +from infrahub_sdk.schema.generated.contract import READ_ONLY_FIELDS +from pydantic import BaseModel from infrahub.core.constants import Visibility from infrahub.core.schema.definitions.internal import ( @@ -110,6 +114,78 @@ def test_write_model_publishes_allowed_values(family: str) -> None: ) +@dataclass(frozen=True) +class ReadOnlyDeltaCase: + name: str + write_model: type[BaseModel] + read_model: type[BaseModel] + + +READ_ONLY_DELTA_CASES = [ + ReadOnlyDeltaCase( + name="attribute", + write_model=sdk_write.AttributeSchemaBaseWrite, + read_model=sdk_read.AttributeSchemaBaseRead, + ), + ReadOnlyDeltaCase( + name="relationship", + write_model=sdk_write.RelationshipSchemaWrite, + read_model=sdk_read.RelationshipSchemaRead, + ), + ReadOnlyDeltaCase( + name="base_node", + write_model=sdk_write.BaseNodeSchemaWrite, + read_model=sdk_read.BaseNodeSchemaRead, + ), + ReadOnlyDeltaCase( + name="node", + write_model=sdk_write.NodeSchemaWrite, + read_model=sdk_read.NodeSchemaRead, + ), + ReadOnlyDeltaCase( + name="generic", + write_model=sdk_write.GenericSchemaWrite, + read_model=sdk_read.GenericSchemaRead, + ), +] + + +@pytest.mark.parametrize("case", [pytest.param(tc, id=tc.name) for tc in READ_ONLY_DELTA_CASES]) +def test_read_only_table_matches_the_read_write_delta(case: ReadOnlyDeltaCase) -> None: + """The generated read-only table is exactly what the read model adds over the write model. + + The table decides whether an extra field in a submitted payload is a warning or an error, so a + field appearing on read but missing from the table would be rejected instead of tolerated, and + breaking the read-back, edit, re-load round trip. Each entry resolves what the class inherits, + so a consumer looks it up by name without walking the model hierarchy. + """ + # A computed field is part of the read payload even though it is not a model field. + read_names = set(case.read_model.model_fields) | set(case.read_model.model_computed_fields) + + assert READ_ONLY_FIELDS[case.write_model.__name__] == read_names - set(case.write_model.model_fields) + + +def test_read_only_table_entries_are_fully_resolved() -> None: + """No generated write class inherits a read-only field its own table entry omits. + + The offline validator looks a class up by name alone. Were the generator to emit only a class's + own read-only fields, an inherited one would fall through as an unknown field, turning a value + that must be tolerated on a round trip into a hard error. + """ + incomplete: dict[str, list[str]] = {} + for obj in vars(sdk_write).values(): + if not (isinstance(obj, type) and issubclass(obj, BaseModel)): + continue + inherited: set[str] = set() + for base in obj.__mro__[1:]: + inherited |= READ_ONLY_FIELDS.get(base.__name__, frozenset()) + missing = inherited - READ_ONLY_FIELDS.get(obj.__name__, frozenset()) + if missing: + incomplete[obj.__name__] = sorted(missing) + + assert incomplete == {} + + def test_attribute_kind_variants_partition_all_kinds() -> None: """The attribute union's variants together cover every attribute kind, without overlap. diff --git a/changelog/+infp-234-schema-load-read-only.fixed.md b/changelog/+infp-234-schema-load-read-only.fixed.md deleted file mode 100644 index dba5466ed69..00000000000 --- a/changelog/+infp-234-schema-load-read-only.fixed.md +++ /dev/null @@ -1 +0,0 @@ -The `/api/schema/load` endpoint now tolerates read-only, internal, and unknown fields on a submitted schema (for example a schema read back from Infrahub, or an exported schema), dropping them silently before applying the write contract instead of rejecting the payload. Constrained values and structural requirements are still validated. diff --git a/changelog/+infp-234.changed.md b/changelog/+infp-234.changed.md index 0d469987c6a..36ca0ee7e9d 100644 --- a/changelog/+infp-234.changed.md +++ b/changelog/+infp-234.changed.md @@ -1,7 +1,21 @@ -`POST /api/schema/load` now validates every submitted node, generic, and extension against a user-facing *write* contract. Constrained fields (for example an attribute `kind` or a relationship `cardinality`) set outside their allowed values, out-of-range values, and missing required fields are rejected with a field-level error naming the field and the invalid value. +`POST /api/schema/load` now validates every submitted node, generic, and extension against a user-facing *write* contract, and reports the fields it does not apply instead of ignoring them. -Fields a user may not set — read-only or internal fields such as `inherited`, `used_by`, `hierarchy`, and a derived `kind` on nodes/generics — as well as fields that are genuinely unknown (a typo, or a field removed in a newer version) are dropped silently before the schema is applied, so a schema read back from Infrahub or hand-edited still loads. +Constrained fields (for example an attribute `kind` or a relationship `cardinality`) set outside their allowed values, and out-of-range values, are rejected with a field-level error naming the field and the invalid value: + +```text +nodes[0].relationships[0].cardinality: Input should be 'one' or 'many' (received: 'several') +``` + +Attribute `parameters` belonging to a different attribute `kind` are rejected too — for example `start_range` on a `Number` attribute, which earlier versions accepted and then discarded, so the setting silently had no effect. + +Fields Infrahub computes and owns are accepted and reported as a warning, one per distinct field, naming every kind and element that carried it. These are `inherited`, `used_by`, `hierarchy`, a relationship's `hierarchical`, a node's derived `kind` and `hash`, and the bookkeeping a schema dumped from Infrahub carries on nested blocks such as `parameters`. The submitted value is ignored, so reading a schema back from Infrahub, editing it, and loading it again keeps working: + +```text +'inherited' is a read-only field, the submitted value is ignored [InfraDevice.name, InfraDevice.interfaces] +``` + +`infrahubctl schema load` and `infrahubctl schema check` print these warnings; `infrahubctl validate schema` reports them offline. A field the contract does not recognize at all — a typo, or a field removed in a newer version — is rejected as before, now with the same field-level path. `GET /api/schema` returns the same response body as before and still includes read-only fields such as `inherited` and `used_by`. Its OpenAPI component schemas are now named after the generated read models — `NodeSchemaRead`, `GenericSchemaRead`, `ProfileSchemaRead`, and `TemplateSchemaRead` in place of `APINodeSchema`, `APIGenericSchema`, `APIProfileSchema`, and `APITemplateSchema` — so a client generating types from `openapi.json` needs to update those type names. -The write contract is published as a committed model in the Python SDK (`infrahub_sdk.schema.generated.write`); the SDK `validate_schema()` helper reproduces the server verdict offline so a payload can be checked before submission. +The write contract is published as a committed model in the Python SDK (`infrahub_sdk.schema.generated.write`); the SDK `validate_schema()` helper reproduces the server verdict offline, including the warnings, so a payload can be checked before submission. `client.schema.validate()` reaches the same verdict and raises `ValueError` rather than a pydantic `ValidationError`. diff --git a/changelog/+schema-load-unchanged-warnings.fixed.md b/changelog/+schema-load-unchanged-warnings.fixed.md new file mode 100644 index 00000000000..b9c2087e873 --- /dev/null +++ b/changelog/+schema-load-unchanged-warnings.fixed.md @@ -0,0 +1 @@ +`POST /api/schema/load` now returns the warnings it collected even when the submitted schema matches the one already loaded. Previously the response for an unchanged schema omitted them, so a deprecation or read-only-field warning went unreported on every load after the first. diff --git a/dev/adr/0010-generated-user-facing-schema-contract.md b/dev/adr/0010-generated-user-facing-schema-contract.md index 4e8c6c18cbc..d2da83f0833 100644 --- a/dev/adr/0010-generated-user-facing-schema-contract.md +++ b/dev/adr/0010-generated-user-facing-schema-contract.md @@ -34,9 +34,18 @@ and emitted into the Python SDK as committed, shipped artifacts. The server vali through the SDK-hosted write models rather than through a backend-local copy, so one implementation produces both the server's verdict and the client's offline verdict. -Submission **ignores** fields outside the write contract rather than rejecting them: read-only, -internal, and unknown fields are dropped, and only invalid *settable* values are rejected. This -half of the decision reverses the original design, which rejected them. +Submission never applies a field outside the write contract, and reports it according to what it +is. A field the contract knows at that location but the user may not set — a read-only field, the +bookkeeping a schema dumped from the internal models carries, a field belonging to a sibling +variant of a discriminated union — is dropped and reported as a warning. Any other extra field is +rejected, because the only ways to produce one are a typo and a field that no longer exists. +Invalid *settable* values are rejected as before. + +The warning/error split is driven by a generated table of the non-settable fields of each write +class, and applied by walking the submitted payload alongside the validated write document: the +validated document resolves which model applies at each location, including which member of a +discriminated union an attribute matched, so the payload is compared against the fields that +location actually accepts. `invoke backend.validate-generated` regenerates the models and fails on any diff, in the backend tree and inside the SDK submodule, and CI runs it. A unit test asserts that no field classified @@ -66,10 +75,18 @@ How the visibility axis, the generated families, and the load boundary work is d direction. - Changing a schema field spans two repositories: the generated artifact must be regenerated, committed in the submodule, and released in step with the server. -- A mistyped field name is silently dropped instead of reported, so a typo in a schema file fails - quietly rather than loudly. Revisit this if it proves to be a common source of confusion — - reporting unrecognised fields as warnings would preserve the round-trip while restoring the - feedback. +- One payload shape that the last released version accepted now fails: attribute `parameters` + belonging to a different attribute `kind`, which were accepted and then discarded, so the schema + quietly differed from the one the author wrote. The cost of reporting them is that a repository + whose committed schema carries one stops importing until it is corrected. A mistyped field name + was already rejected before this work, since the load endpoint validated through a model with + `extra="forbid"`; only the wording of that rejection changes. +- Extra fields are reported only once the payload validates against the write models, since the + validated document is what resolves the contract applying at each location. A payload rejected + for another reason names its extra fields on the next run rather than in the same response. +- Read-only fields ride the existing `deprecation` warning type rather than a dedicated one, so + that an SDK older than this change can still parse a load response. A dedicated type has to wait + until an SDK tolerant of unknown warning types is the supported floor. ### Neutral @@ -98,7 +115,25 @@ be proven by a test rather than following from construction. The original design — implemented, then reversed in review. Rejecting them broke the read → edit → load path for every client that round-trips a schema, and what it bought was a clearer error for a field the user had not intended to set. An `ignore_extras` opt-in flag was also considered and -rejected: tolerance as the default achieves the same result without a second code path. +rejected: tolerance as the default achieves the same result without a second code path. Splitting +the two cases, as the decision above now does, keeps the round-trip working *and* restores the +error for the fields where an error is the only useful answer. + +### Generate a third model family to classify extra fields + +A `tolerant` variant carrying the read-level field set with `extra="allow"`, validated alongside +the write models, would have let pydantic classify extra fields with no traversal code. Rejected as +disproportionate: it is a second full family of generated models, and a validated write document +plus a generated table of non-settable field names answers the same question. It also risked +extras leaking into the document the server loads. + +### Classify extra fields with a `mode="before"` validator on the write models + +A hook on every generated write class, appending findings to the pydantic validation context, needs +no traversal code and fires even when validation fails elsewhere. Rejected because a before-validator +does not know where it sits in the document, so a finding could name neither the path nor the owning +kind — and reconstructing the parent chain would mean stateful validators pushing and popping +context. ### Filter non-write fields with a hand-written projection step diff --git a/dev/knowledge/backend/schema-definitions.md b/dev/knowledge/backend/schema-definitions.md index 68a7efe126d..591187e3fb4 100644 --- a/dev/knowledge/backend/schema-definitions.md +++ b/dev/knowledge/backend/schema-definitions.md @@ -114,9 +114,9 @@ renders two model families from them by filtering each field on its visibility l - **write models** — include only `WRITE` fields and set `extra="ignore"`, so a read-only, internal, or unknown field in a submitted payload is dropped by pydantic itself instead of - rejected. That holds at every nesting level, and the per-kind discriminated unions - (attribute kinds, computed-attribute kinds) resolve first, so each variant keeps only the - fields valid for it. Constrained values that *are* settable are still validated and + reaching the loaded schema. That holds at every nesting level, and the per-kind discriminated + unions (attribute kinds, computed-attribute kinds) resolve first, so each variant keeps only + the fields valid for it. Constrained values that *are* settable are still validated and rejected when out of range. No hand-written filtering step is needed at the boundary. - **read models** — include `WRITE` and `READ` fields, describing the shape returned by `GET /api/schema`. @@ -124,6 +124,34 @@ renders two model families from them by filtering each field on its visibility l Because both families are generated from the same definitions, a field's classification is declared once and both the write contract and the read shape follow automatically. +### Reporting the fields a payload sets but the contract drops + +`extra="ignore"` decides that a non-write field has no effect; it does not decide whether the +user hears about it. That split is driven by a third generated artifact, +`python_sdk/infrahub_sdk/schema/generated/contract.py`, holding the non-settable field names of +each write class: + +- a name **in** the table is dropped and reported as a **warning** — a `READ`-level field, the + `id`/`state` bookkeeping every internal schema model carries on the nested value models + (parameters, choices, computed attribute, extensions), or a field belonging to a sibling + variant of a discriminated union. These are all names a schema read back from Infrahub can + carry, so accepting them is what keeps the read → edit → load round trip working. +- a name **not** in the table is an **error**, since the only ways to produce one are a typo and + a field that no longer exists. + +The table is built by `SdkSchemaGenerator._read_only_fields` from two diffs: the read variant of +a class against its write variant, and the internal pydantic counterpart of a value model against +its generated write model. Root-level keys that `GET /api/schema` adds over the write root +(`main`, `profiles`, `templates`, `namespaces`) are listed explicitly and pinned against +`SchemaReadAPI` by a test. + +Applying the table means knowing which model governs each place in the payload. +`_collect_extra_fields` in `validate.py` walks the submitted payload alongside the *validated* +write document: the validated document resolves the model at every location — including which +union member an attribute matched — so the raw keys are compared against the fields that location +actually accepts. One consequence: extra fields are reported only once the payload validates, +so a payload rejected for another reason names them on the next run. + A field whose valid values are a closed set is generated as a dedicated `(str, Enum)` class in `python_sdk/infrahub_sdk/schema/generated/enums.py` and referenced by both families, rather than as a bare `str` or an inline `Literal`. The allowed values therefore travel with the model, so a @@ -160,6 +188,7 @@ change; CI fails if the generated artifact is stale. | `Visibility` enum | `backend/infrahub/core/constants/schema.py` | | SDK write/read generator | `SdkSchemaGenerator` in `tasks/backend.py` | | Generated SDK write/read models (do not edit) | `python_sdk/infrahub_sdk/schema/generated/{write,read}.py` | +| Generated non-settable field table (do not edit) | `python_sdk/infrahub_sdk/schema/generated/contract.py` | | Offline write-contract validator | `python_sdk/infrahub_sdk/schema/validate.py` | | RelationshipSchema class | `backend/infrahub/core/schema/relationship_schema.py` | | AttributeSchema class | `backend/infrahub/core/schema/attribute_schema.py` | @@ -171,4 +200,4 @@ change; CI fails if the generated artifact is stale. - [Code Generation](code-generation.md) — How schema definitions become generated code - [Database Schema](database-schema.md) — How schemas map to Neo4j graph structure - [ADR 0010](../../adr/0010-generated-user-facing-schema-contract.md) — Why the user-facing - contract is generated into the SDK, and why submission ignores non-write fields + contract is generated into the SDK, and how submission reports the fields it does not apply diff --git a/docs/docs/release-notes/deprecation-guides/schema-load-write-contract.mdx b/docs/docs/release-notes/deprecation-guides/schema-load-write-contract.mdx index 103e567551d..88605f71874 100644 --- a/docs/docs/release-notes/deprecation-guides/schema-load-write-contract.mdx +++ b/docs/docs/release-notes/deprecation-guides/schema-load-write-contract.mdx @@ -5,9 +5,9 @@ user-facing **write** contract. The contract defines which fields a user may set values those fields accept, and which fields are required. Invalid values are reported as field-level errors naming the field and the value that was rejected. -Fields a user may not set are **tolerated and dropped**, not rejected, so reading a schema -back from Infrahub, editing it, and loading it again keeps working without stripping -anything first. +Fields a user may not set are **reported and dropped**. A read-only field — one Infrahub +returns and computes itself — is reported as a warning, so reading a schema back from +Infrahub, editing it, and loading it again keeps working without stripping anything first. This guide explains what changed, who is affected, and how to check a payload before submitting it. @@ -15,7 +15,8 @@ submitting it. :::warning Two categories of payload that earlier versions accepted are now rejected: a payload whose settable values are invalid (out of the allowed set, out of range, or the wrong type), and -a payload with no `version` key. Extra fields are not one of those categories. +attribute `parameters` that belong to a different attribute `kind`. Read-only fields are not +one of those categories — they are accepted with a warning. ::: ## What changed @@ -32,30 +33,67 @@ Schema fields are now classified by who may see or set them: `POST /api/schema/load` validates each submitted node, generic, and extension against the write contract. -### Extra fields are dropped +### Read-only fields are reported as a warning -Read-only fields, internal fields, and fields the contract does not know about at all — a -typo, or a field removed in a newer version — are dropped before the schema is applied. This -happens at every level of the payload: on nodes, generics, extensions, and on their nested -attributes and relationships. No error is raised, and the dropped values have no effect on -the loaded schema. +A read-only field is dropped before the schema is applied and reported on the response, so the +submitted value never takes effect but you are told it was ignored. This happens at every level +of the payload: on nodes, generics, extensions, and on their nested attributes, relationships, +parameters and choices. That means the read-back, edit, re-load round trip works as it always did. A schema fetched with `GET /api/schema` carries `inherited`, `used_by`, and the derived `kind`; submitting it -back unchanged is accepted, and Infrahub re-derives those fields itself. +back unchanged is accepted, Infrahub re-derives those fields itself, and the response lists +one warning per read-only field naming the kinds that carried it: + +```text +'inherited' is a read-only field, the submitted value is ignored [InfraDevice.name, InfraDevice.interfaces] +``` + +Alongside the read-only fields listed above, this also covers a field that belongs somewhere +else in the contract rather than at the place it was used: the `id` and `state` bookkeeping +that a schema dumped from Infrahub's own models carries on nested blocks, and a field +belonging to another variant of the same block — for example `transform` on a computed +attribute whose `kind` is `Jinja2`. + +`infrahubctl schema load` and `infrahubctl schema check` print these warnings after loading; +`infrahubctl validate schema` prints them without contacting a server. + +### Unrecognized fields are rejected + +A field the contract does not know about at all — a typo, or a field removed in a newer +version — is an error naming the field path and the value received: + +```text +nodes[0].attributes[0].optionl: Unknown field, it is not part of the schema (received: True) +``` + +Earlier versions rejected these too, so this is not a change in whether the payload loads — +only in how the problem is reported. The path is now field-level, and the same message is +available offline before you submit. + +Attribute `parameters` that belong to a different attribute `kind` are a change: they +configure nothing on the kind they were set on, and earlier versions accepted them and then +discarded the values, so the setting silently had no effect. + +```text +nodes[0].attributes[0].parameters.start_range: Unknown field, it is not part of the schema (received: 1) +``` ### Invalid values are rejected -What the contract does reject is a field a user *may* set carrying a value it cannot hold: +What the contract does reject is a field a user *may* set carrying a value it cannot hold. +These two are new: - A **constrained** field set outside its allowed values, for example a relationship `cardinality` or an attribute `kind`. - A value **out of range** or of the wrong type for its field, for example an attribute `name` that is shorter than the minimum length or does not match the allowed pattern. + +These two were already rejected and are listed only so the contract reads completely: + - A **missing required** field, for example a node without a `name`, or an attribute without a `kind`. -- A missing `version` at the root of the payload. `version` is required on the write - contract. +- A missing `version` at the root of the payload. Each rejection names the field path and, where applicable, the value received: @@ -81,12 +119,13 @@ and `used_by`. ## Who is affected You are affected if you submit schemas to `POST /api/schema/load` — directly, through -`infrahubctl schema load`, or through the Python SDK — and your payload sets a value the -write contract does not accept, or omits `version`. Values that a previous version stored -without complaint now produce an error instead. +`infrahubctl schema load`, through a repository import, or through the Python SDK — and your +payload sets a value the write contract does not accept, or sets attribute `parameters` that +belong to a different attribute `kind`. Values that a previous version stored, or accepted and +discarded, without complaint now produce an error instead. -You are not affected by extra fields. A payload that echoes back derived or read-only -fields, or that contains a field Infrahub no longer recognizes, still loads. +You are not affected by read-only fields. A payload that echoes back derived or read-only +fields still loads, with a warning for each. ## Check a payload offline @@ -100,6 +139,8 @@ from infrahub_sdk.schema.validate import validate_schema # schema is your schema-root payload: {"version": "1.0", "nodes": [...], "generics": [...]} result = validate_schema(schema=schema) +for message in result.warning_messages: + print(message) if not result.valid: for message in result.messages: print(message) @@ -115,7 +156,10 @@ nodes[0].relationships[0].cardinality: Input should be 'one' or 'many' (received Every entry in `result.errors` carries the field path in `.field` and the full message in `.message`. `result.raise_for_status()` raises a `ValueError` joining all the messages when -the payload is invalid. +the payload is invalid. Each entry in `result.warnings` additionally carries the schema kind +and element that set the field in `.kind` and `.element`, and in `.name` the field named +relative to them — so a nested block is unambiguous (`parameters.id` rather than `id`, which +*is* settable on the attribute itself). To make a rejected payload submittable: @@ -123,11 +167,18 @@ To make a rejected payload submittable: value. 2. Correct the value so it satisfies the contract — pick an allowed value for a constrained field, bring an out-of-range value into range, or supply a missing required field. -3. Add `version` at the root of the payload if it is absent. -4. Re-run `validate_schema()` until it returns `valid = True`, then load. - -You do not need to remove read-only or unrecognized fields. Leaving them in place is -harmless. +3. Remove or fix any field reported as unknown. A typo is the usual cause; check the spelling + against the [node schema reference](../../reference/schema/node). +4. Add `version` at the root of the payload if it is absent. +5. Re-run `validate_schema()` until it returns `valid = True`, then load. + +You do not need to remove read-only fields. Leaving them in place is harmless, and the +warnings tell you which values had no effect. + +Extra fields are only reported once the payload is otherwise valid, because the contract that +applies at a given place in the payload is resolved from the validated document. A payload +rejected for another reason reports that reason first, and names its extra fields on the next +run. Because the write model is versioned and shipped inside the SDK package, installing the SDK that matches your Infrahub version gives you the exact contract the server enforces. diff --git a/python_sdk b/python_sdk index 204f169a2a6..f9e28cfd595 160000 --- a/python_sdk +++ b/python_sdk @@ -1 +1 @@ -Subproject commit 204f169a2a67712b5b3a5b65cc17f13ac668ee27 +Subproject commit f9e28cfd5958946759f113fd9fe29422adc8fcea diff --git a/tasks/backend.py b/tasks/backend.py index a2cd0b52c1c..3bc76ffd59f 100644 --- a/tasks/backend.py +++ b/tasks/backend.py @@ -636,12 +636,47 @@ def _sdk_kind_field( def _write_sdk_generated_init(generated: str) -> None: init_content = ( '# Generated by "invoke backend.generate", do not edit directly\n' - "from . import enums, read, write\n\n" - '__all__ = ["enums", "read", "write"]\n' + "from . import contract, enums, read, write\n\n" + '__all__ = ["contract", "enums", "read", "write"]\n' ) Path(f"{generated}/__init__.py").write_text(init_content, encoding="utf-8") +# Top-level keys `GET /api/schema` returns that have no counterpart on the write root. They are +# read-only rather than unknown, so a raw response body resubmitted to the load endpoint warns +# instead of failing. Pinned against the API response model by a backend test. +_ROOT_READ_ONLY_FIELDS = frozenset({"main", "namespaces", "profiles", "templates"}) + + +def _family_field_name(attribute: "SchemaAttribute | dict[str, str]") -> str: + """Name of a family field, declared either as a SchemaAttribute or as a pre-rendered dict.""" + return attribute["name"] if isinstance(attribute, dict) else attribute.name + + +def _write_sdk_generated_contract(generated: str, read_only_fields: dict[str, frozenset[str]]) -> None: + entries = "" + for class_name, fields in sorted(read_only_fields.items()): + names = ", ".join(f'"{name}"' for name in sorted(fields)) + entries += f' "{class_name}": frozenset({{{names}}}),\n' + content = ( + '# Generated by "invoke backend.generate", do not edit directly\n' + '"""Read-only fields of the write contract, keyed by generated write class name.\n' + "\n" + "A field listed here is one the contract knows at that location but the user may not set:\n" + "a field the read API returns, the bookkeeping a schema dumped from the internal models\n" + "carries, or a field belonging to a sibling variant of a discriminated union. Submitting one\n" + "is reported as a warning and the value is dropped, where an extra field that is not listed\n" + "is an error. Each entry already includes what the class inherits, so a lookup is by class\n" + "name alone.\n" + '"""\n' + "\n" + "READ_ONLY_FIELDS: dict[str, frozenset[str]] = {\n" + f"{entries}" + "}\n" + ) + Path(f"{generated}/contract.py").write_text(content, encoding="utf-8") + + @dataclass(frozen=True) class SdkVariant: """One generated module of the SDK schema family (``write.py`` / ``read.py``).""" @@ -834,6 +869,7 @@ def generate(self) -> None: rendered = rendered.replace("__VARIANT__", variant.suffix) Path(f"{self.generated}/{variant.name}.py").write_text(rendered, encoding="utf-8") + _write_sdk_generated_contract(self.generated, self._read_only_fields()) _write_sdk_generated_init(self.generated) execute_command(context=self.context, command=f'ruff format "{self.generated}"') @@ -1044,6 +1080,98 @@ def _families(self, minimum: "Visibility", suffix: str) -> list[dict[str, Any]]: }, ] + def _families_for_contract(self, minimum: "Visibility") -> list[dict[str, Any]]: + """Every generated write family, at the given field visibility.""" + suffix = "Write" + return self._pre_families(minimum, suffix) + self._families(minimum, suffix) + _sdk_extension_families(suffix) + + @staticmethod + def _declared_names(families: list[dict[str, Any]]) -> dict[str, set[str]]: + """Field names each family declares itself, excluding what it inherits.""" + names: dict[str, set[str]] = {} + for family in families: + fields = {_family_field_name(attribute) for attribute in family["attributes"]} + # A computed field is part of the payload only for the variant that serializes it. + fields |= {computed["name"] for computed in family.get("computed_fields", []) if computed["serialize"]} + names[family["class_name"]] = fields + return names + + @staticmethod + def _inherited_names(families: list[dict[str, Any]], declared: dict[str, set[str]]) -> dict[str, set[str]]: + """Field names each family accepts, following its parent chain.""" + parents = {family["class_name"]: family["parent"] for family in families} + resolved: dict[str, set[str]] = {} + for class_name in declared: + names: set[str] = set() + current: str | None = class_name + while current in declared: + names |= declared[current] + current = parents.get(current) + resolved[class_name] = names + return resolved + + def _value_model_read_only_fields(self, write_names: dict[str, set[str]]) -> dict[str, set[str]]: + """Non-settable fields of the value models, taken from their internal counterparts. + + The parameters, choice, computed-attribute and extension models are declared in this + generator rather than derived from internal.py, so they omit the bookkeeping fields every + internal schema model carries (``id``, ``state``) and each computed-attribute variant omits + the fields of its siblings. All of those names appear in a schema dumped from the internal + models, so they are tolerated with a warning rather than reported as unknown. + """ + from infrahub.core.schema import NodeExtensionSchema, SchemaExtension + from infrahub.core.schema import attribute_parameters as parameters_module + from infrahub.core.schema.computed_attribute import ComputedAttribute + from infrahub.core.schema.dropdown import DropdownChoice + + suffix = "Write" + internal_counterparts: dict[str, Any] = { + f"DropdownChoice{suffix}": DropdownChoice, + f"SchemaExtension{suffix}": SchemaExtension, + f"NodeExtension{suffix}": NodeExtensionSchema, + } + for variant in ("User", "Jinja2", "TransformPython"): + internal_counterparts[f"ComputedAttribute{variant}{suffix}"] = ComputedAttribute + for _, parameters_name in self.attribute_variant_specs: + internal_counterparts[f"{parameters_name}{suffix}"] = getattr(parameters_module, parameters_name) + + return { + class_name: set(internal.model_fields) - write_names.get(class_name, set()) + for class_name, internal in internal_counterparts.items() + } + + def _read_only_fields(self) -> dict[str, frozenset[str]]: + """Field names a generated write class does not accept but a submitted schema may carry. + + Two sources feed the table: a field the read variant of a class declares and the write + variant does not, and a field the internal counterpart of a value model declares and the + generated write model does not. Deriving both here rather than reading the emitted modules + keeps the table in step with the definitions the models themselves come from. + + Each entry is resolved through the parent chain, so a consumer looks a class up by name and + needs to know nothing about how the generated models inherit from each other. + """ + from infrahub.core.constants import Visibility + + write_families = self._families_for_contract(Visibility.WRITE) + write_declared = self._declared_names(write_families) + read_declared = self._declared_names(self._families_for_contract(Visibility.READ)) + + # An entry is kept for every class, including the empty ones, so resolving through the + # parent chain does not stop at a class that declares no read-only field of its own. + declared = { + class_name: fields - write_declared.get(class_name, set()) for class_name, fields in read_declared.items() + } + for class_name, fields in self._value_model_read_only_fields( + write_names=self._inherited_names(families=write_families, declared=write_declared) + ).items(): + declared[class_name] = declared.get(class_name, set()) | fields + + read_only = self._inherited_names(families=write_families, declared=declared) + read_only["InfrahubSchemaWrite"] = set(_ROOT_READ_ONLY_FIELDS) + + return {class_name: frozenset(fields) for class_name, fields in read_only.items() if fields} + def _base_node_family(self, minimum: "Visibility", suffix: str) -> dict[str, Any]: """Base node family shared by node/generic/profile/template. From 7d3fca3b7e2d645fd5a4e51db44826970946ff72 Mon Sep 17 00:00:00 2001 From: Patrick Ogenstad Date: Mon, 3 Aug 2026 10:57:36 +0200 Subject: [PATCH 72/75] fix(fingerprint): trust dependencies_complete for Jinja2 transforms (#10096) * fix(fingerprint): trust dependencies_complete for Jinja2 transforms Make the commit-id fold gate builder-aware instead of uniform. A Jinja2 transform with a complete dependency closure now produces a stable fingerprint without an explicit watch declaration, so unrelated commits no longer churn its fingerprint. Jinja2 completeness is sound: it comes from a real transitive parse of the template include/import/extends graph, so a complete closure alone is enough. A dynamic or unresolvable reference still drops completeness to False and keeps the commit id folded. Python transforms and generators are unchanged - their completeness flag is only the package-directory floor, so a present watch remains required to omit the commit id. Amends spec FR-016/FR-019 with a builder-aware note. Ref: IFC-2952 --- backend/infrahub/git/fingerprint/composer.py | 67 ++++++++++---- .../git/test_fingerprint_transformation.py | 27 ++++++ .../test_composer_transformation.py | 63 +++++++++++++ .../unit/git/fingerprint/test_watch_state.py | 92 +++++++++++++++---- .../ifc-2844-definition-fingerprint/spec.md | 9 +- 5 files changed, 224 insertions(+), 34 deletions(-) diff --git a/backend/infrahub/git/fingerprint/composer.py b/backend/infrahub/git/fingerprint/composer.py index 3d72724a46f..c99f4b8ffc5 100644 --- a/backend/infrahub/git/fingerprint/composer.py +++ b/backend/infrahub/git/fingerprint/composer.py @@ -19,21 +19,36 @@ from infrahub.git.fingerprint.blob_resolver import BlobResolver -def fold_commit_id(*, commit: str, watch: InfrahubWatchConfig | None, closure_complete: bool) -> str | None: - """Return the commit id to fold into a fingerprint, or None to omit it. - - The commit id is folded (making the fingerprint change on every commit, the safe - over-regenerating default) whenever the definition's inputs cannot be pinned down: - - - `watch` is absent (`None`): the definition has not declared its dependencies. - - the dependency closure is incomplete: some output-affecting dependency could not be - resolved, so hashing only the resolved paths would leave the fingerprint stable over - an input the system already knows is unknown - an under-regeneration risk. - - Only a definition with a present `watch` and a complete closure omits the commit id and - gets a stable, precise fingerprint. +def fold_commit_id( + *, commit: str, watch: InfrahubWatchConfig | None, closure_complete: bool, watch_required: bool +) -> str | None: + """Return the commit id to mix into a fingerprint, or None to leave it out. + + Mixing the commit id in makes the fingerprint change on every commit: the definition is + regenerated more often than it needs to be, but a change is never missed. Leaving it out + makes the fingerprint change only when one of the listed dependencies changes. So the + commit id goes in whenever we cannot be sure the dependency list names every file that + affects the definition's output. + + `closure_complete=False` means the dependency scan gave up on at least one reference, so + the system already knows a file is missing from the list. The commit id always goes in. + + When the list is complete, how far that can be trusted depends on how it was built, which + the caller states through `watch_required`: + + - `watch_required=True` - the list is a directory listing rather than a real dependency + scan: every tracked file that happens to sit next to the entry point. "Complete" only + means the listing succeeded, so a helper imported from another directory is missing from + the list without anything noticing. Only a `watch` declaration, where the author names + the extra files by hand, is trusted to close the list; without one the commit id goes in. + - `watch_required=False` - the list was built by parsing the source and following every + reference it declares, and any reference that could not be followed already set + `closure_complete=False`. A complete list is therefore trustworthy by itself, and no + `watch` declaration is needed to leave the commit id out. """ - if watch is None or not closure_complete: + if not closure_complete: + return commit + if watch_required and watch is None: return commit return None @@ -148,8 +163,16 @@ def compose_transformation( case PythonTransformationFingerprintInput(): terms.append(f"class_name={inputs.class_name}") terms.append(f"convert_query_response={inputs.convert_query_response}") + # A Python transform's dependencies are the files sitting next to its source + # file, so an import from anywhere else is absent from the list: the author has + # to name those files in `watch` before the fingerprint can drop the commit id. + watch_required = True case Jinja2TransformationFingerprintInput(): terms.append(f"template_path={inputs.template_path}") + # A Jinja2 transform's dependencies come from parsing the template and following + # every include/import/extends it declares, so a complete list already names + # every file that affects the rendered output. + watch_required = False case _: # pragma: no cover - exhaustiveness guard for a new transform kind assert_never(inputs) @@ -157,6 +180,7 @@ def compose_transformation( watch=inputs.watch, closure_complete=inputs.dependencies_complete, upstream_resolved=query_fingerprint is not None, + watch_required=watch_required, ) if commit_term is not None: terms.append(f"commit_id={commit_term}") @@ -194,6 +218,7 @@ def compose_generator_definition(self, inputs: GeneratorDefinitionFingerprintInp watch=inputs.watch, closure_complete=inputs.dependencies_complete, upstream_resolved=query_fingerprint is not None, + watch_required=True, ) if commit_term is not None: terms.append(f"commit_id={commit_term}") @@ -203,7 +228,12 @@ def compose_generator_definition(self, inputs: GeneratorDefinitionFingerprintInp return fingerprint def _resolve_commit_term( - self, *, watch: InfrahubWatchConfig | None, closure_complete: bool, upstream_resolved: bool + self, + *, + watch: InfrahubWatchConfig | None, + closure_complete: bool, + upstream_resolved: bool, + watch_required: bool, ) -> str | None: """Return the commit-id term for a transform/generator, or None to omit it. @@ -212,7 +242,12 @@ def _resolve_commit_term( the commit id is folded in and the fingerprint can never be stable over an upstream it could not read. """ - return fold_commit_id(commit=self._commit, watch=watch, closure_complete=closure_complete and upstream_resolved) + return fold_commit_id( + commit=self._commit, + watch=watch, + closure_complete=closure_complete and upstream_resolved, + watch_required=watch_required, + ) def _closure_term(self, dependencies: Iterable[str]) -> str: hashed_paths = self._closure_selector.select(dependencies) diff --git a/backend/tests/integration/git/test_fingerprint_transformation.py b/backend/tests/integration/git/test_fingerprint_transformation.py index 9dffadc2b5c..f5841a0a03b 100644 --- a/backend/tests/integration/git/test_fingerprint_transformation.py +++ b/backend/tests/integration/git/test_fingerprint_transformation.py @@ -54,6 +54,33 @@ async def test_connected_query_edit_changes_transformation_fingerprint( after = (await client.get(kind=CoreTransformJinja2, name__value="person_with_cars")).fingerprint.value assert before != after + async def test_unrelated_commit_keeps_complete_jinja2_stable_but_folds_python( + self, repository_id: str, client: InfrahubClient, file_repo: FileRepo + ) -> None: + # Neither transform declares a watch, and the commit below touches neither of them. + # person_with_cars is a Jinja2 transform whose template includes nothing, so parsing it + # found every file that affects the output and the fingerprint can ignore the commit id. + # CarSpecMarkdown is a Python transform: its dependencies are just the files next to it + # on disk, which could always be missing an import from another directory, so its + # fingerprint keeps following the commit id. + jinja2_before = (await client.get(kind=CoreTransformJinja2, name__value="person_with_cars")).fingerprint.value + python_before = (await client.get(kind=CoreTransformPython, name__value="CarSpecMarkdown")).fingerprint.value + assert jinja2_before + assert python_before + + await self._commit_edit_and_reimport( + client=client, + repository_id=repository_id, + file_repo=file_repo, + edits={"README.md": _append_comment("README.md", file_repo)}, + ) + + jinja2_after = (await client.get(kind=CoreTransformJinja2, name__value="person_with_cars")).fingerprint.value + python_after = (await client.get(kind=CoreTransformPython, name__value="CarSpecMarkdown")).fingerprint.value + + assert jinja2_after == jinja2_before + assert python_after != python_before + def _append_comment(relative_path: str, file_repo: FileRepo) -> str: current = (Path(file_repo.path) / relative_path).read_text(encoding="utf-8") diff --git a/backend/tests/unit/git/fingerprint/test_composer_transformation.py b/backend/tests/unit/git/fingerprint/test_composer_transformation.py index 5cb37ec31a4..46c537ebdd3 100644 --- a/backend/tests/unit/git/fingerprint/test_composer_transformation.py +++ b/backend/tests/unit/git/fingerprint/test_composer_transformation.py @@ -1,5 +1,8 @@ from __future__ import annotations +from dataclasses import dataclass + +import pytest from infrahub_sdk.schema.repository import InfrahubWatchConfig from infrahub.git.closure_builder.post_processing import MANIFEST_PATH @@ -151,3 +154,63 @@ def digest(blobs: dict[str, str]) -> str: return build_composer(blob_shas=blobs, registry=registry).compose_transformation(_jinja2_input()) assert digest(J2_BLOBS) == digest({**J2_BLOBS, MANIFEST_PATH: "sha-manifest-edited"}) + + +@dataclass +class Jinja2CommitFoldCase: + name: str + watch: InfrahubWatchConfig | None + dependencies_complete: bool + seed_query: bool + stable: bool + + +JINJA2_COMMIT_FOLD_CASES = [ + Jinja2CommitFoldCase( + name="complete_closure_without_watch_is_stable", + watch=None, + dependencies_complete=True, + seed_query=True, + stable=True, + ), + Jinja2CommitFoldCase( + name="complete_closure_with_empty_watch_is_stable", + watch=InfrahubWatchConfig(files=[]), + dependencies_complete=True, + seed_query=True, + stable=True, + ), + Jinja2CommitFoldCase( + name="incomplete_closure_without_watch_folds_commit_id", + watch=None, + dependencies_complete=False, + seed_query=True, + stable=False, + ), + Jinja2CommitFoldCase( + name="unresolved_query_folds_commit_id_despite_complete_closure", + watch=None, + dependencies_complete=True, + seed_query=False, + stable=False, + ), +] + + +@pytest.mark.parametrize("case", JINJA2_COMMIT_FOLD_CASES, ids=lambda case: case.name) +def test_jinja2_commit_id_folding(case: Jinja2CommitFoldCase) -> None: + # A Jinja2 transform's dependency list is parsed out of the template, so a complete one is + # trusted on its own: the fingerprint stays the same across unrelated commits with no watch + # declared. If a reference could not be followed, or the query the transform reads was never + # fingerprinted, the commit id goes back in and the fingerprint changes on every commit. + def digest(*, commit: str) -> str: + registry = FingerprintRegistry() + if case.seed_query: + _seed_query(registry) + return build_composer(blob_shas=J2_BLOBS, commit=commit, registry=registry).compose_transformation( + _jinja2_input(watch=case.watch, dependencies_complete=case.dependencies_complete) + ) + + first = digest(commit="commit-1") + second = digest(commit="commit-2") + assert (first == second) is case.stable diff --git a/backend/tests/unit/git/fingerprint/test_watch_state.py b/backend/tests/unit/git/fingerprint/test_watch_state.py index cd364ada478..2c9da536ee2 100644 --- a/backend/tests/unit/git/fingerprint/test_watch_state.py +++ b/backend/tests/unit/git/fingerprint/test_watch_state.py @@ -1,31 +1,89 @@ from __future__ import annotations +from dataclasses import dataclass + +import pytest from infrahub_sdk.schema.repository import InfrahubWatchConfig from infrahub.git.fingerprint.composer import fold_commit_id -def test_absent_watch_folds_commit_id() -> None: - assert fold_commit_id(commit="commit-1", watch=None, closure_complete=True) == "commit-1" +@dataclass +class FoldCase: + name: str + watch: InfrahubWatchConfig | None + closure_complete: bool + watch_required: bool + folds: bool -def test_present_empty_watch_omits_commit_id() -> None: - assert fold_commit_id(commit="commit-1", watch=InfrahubWatchConfig(files=[]), closure_complete=True) is None +FOLD_CASES = [ + # watch_required=True is how Python transforms and generators behave: their dependency list + # is only the files next to the source file, so it can be missing an import from elsewhere + # and a hand-written watch is the only thing that makes the fingerprint stable. + FoldCase( + name="watch_required_absent_watch_folds", + watch=None, + closure_complete=True, + watch_required=True, + folds=True, + ), + FoldCase( + name="watch_required_empty_watch_omits", + watch=InfrahubWatchConfig(files=[]), + closure_complete=True, + watch_required=True, + folds=False, + ), + FoldCase( + name="watch_required_populated_watch_omits", + watch=InfrahubWatchConfig(files=["helpers/util.py"]), + closure_complete=True, + watch_required=True, + folds=False, + ), + FoldCase( + name="watch_required_incomplete_closure_folds_despite_watch", + watch=InfrahubWatchConfig(files=[]), + closure_complete=False, + watch_required=True, + folds=True, + ), + # watch_required=False is how Jinja2 transforms behave: their dependency list is parsed out + # of the template, so a complete one needs no watch to make the fingerprint stable. An + # incomplete list means a reference could not be followed, which still folds the commit id. + FoldCase( + name="watch_optional_absent_watch_complete_closure_omits", + watch=None, + closure_complete=True, + watch_required=False, + folds=False, + ), + FoldCase( + name="watch_optional_absent_watch_incomplete_closure_folds", + watch=None, + closure_complete=False, + watch_required=False, + folds=True, + ), +] -def test_present_populated_watch_omits_commit_id() -> None: - watch = InfrahubWatchConfig(files=["helpers/util.py"]) - assert fold_commit_id(commit="commit-1", watch=watch, closure_complete=True) is None +@pytest.mark.parametrize("case", FOLD_CASES, ids=lambda case: case.name) +def test_fold_commit_id(case: FoldCase) -> None: + result = fold_commit_id( + commit="commit-1", + watch=case.watch, + closure_complete=case.closure_complete, + watch_required=case.watch_required, + ) + assert result == ("commit-1" if case.folds else None) -def test_absent_and_present_empty_are_distinct_states() -> None: - absent = fold_commit_id(commit="commit-1", watch=None, closure_complete=True) - present_empty = fold_commit_id(commit="commit-1", watch=InfrahubWatchConfig(files=[]), closure_complete=True) - assert absent is not None +def test_absent_and_present_empty_are_distinct_states_when_watch_required() -> None: + absent = fold_commit_id(commit="commit-1", watch=None, closure_complete=True, watch_required=True) + present_empty = fold_commit_id( + commit="commit-1", watch=InfrahubWatchConfig(files=[]), closure_complete=True, watch_required=True + ) + assert absent == "commit-1" assert present_empty is None - - -def test_incomplete_closure_folds_commit_id_even_with_present_watch() -> None: - # An incomplete closure means an output-affecting dependency is unknown, so the commit - # id is folded to avoid a stable fingerprint over an unknown input set. - assert fold_commit_id(commit="commit-1", watch=InfrahubWatchConfig(files=[]), closure_complete=False) == "commit-1" diff --git a/dev/specs/ifc-2844-definition-fingerprint/spec.md b/dev/specs/ifc-2844-definition-fingerprint/spec.md index d1ea2c171e2..53b42cf2c8c 100644 --- a/dev/specs/ifc-2844-definition-fingerprint/spec.md +++ b/dev/specs/ifc-2844-definition-fingerprint/spec.md @@ -153,9 +153,16 @@ The `watch:` declaration in `.infrahub.yml` controls how far the fingerprint can #### Watch semantics - **FR-016**: When `watch` is not declared (`None`), the system MUST fold the current commit id into the fingerprint, so the fingerprint changes on every commit (the safe default that reverts to legacy over-regeneration). + + > **Amendment (IFC-2952, 2026-07-30): builder-aware for Jinja2.** FR-016's uniform "watch absent -> fold commit" rule is superseded for Jinja2 transforms. The commit-id gate is now builder-aware: + > + > - **Jinja2**: stability is gated on `dependencies_complete` alone. Its completeness flag is *sound* - it comes from a real transitive parse of the template `include`/`import`/`extends` graph and is only True when every reference resolved to a tracked template. A complete closure yields a stable, precise fingerprint with **no `watch` required**; a dynamic/unresolvable reference drops `dependencies_complete` to False, which keeps the commit id folded. `watch.files` remains the escape hatch to declare targets auto-detection cannot see. + > - **Python transforms / generators**: FR-016 stands unchanged. Their `dependencies_complete = True` is only the *package-directory floor* (an out-of-directory import is silently missed), so it is *unsound* as a completeness guarantee. A present `watch` remains the only signal trusted to omit the commit id. + > + > Rationale: requiring a no-op `watch: {}` on a Jinja2 transform to unlock a stability the system could already justify was pure ceremony. No under-regeneration is introduced - Python is untouched, and incomplete Jinja2 closures still fold the commit id. Implemented via the `watch_required` branch in `fold_commit_id` (`backend/infrahub/git/fingerprint/composer.py`). See Jira IFC-2952 for the full analysis. - **FR-017**: When `watch` is an explicit empty list (`[]`), the system MUST omit the commit-id placeholder, producing a stable, precise fingerprint (user opt-in asserting no dependencies beyond the auto-detected closure). - **FR-018**: When `watch` lists specific files, the system MUST extend the closure with those files, treat the closure as complete, and produce a stable, precise fingerprint. -- **FR-019**: The watch configuration MUST be able to represent absent (`None`) and explicitly-empty (`[]`) as distinct states, and the fingerprint computation MUST branch on that distinction (absent -> fold commit id; explicitly-empty -> do not). NOTE: today the closure builder treats "watch absent" and "watch present but with no files" identically (both skip closure expansion), so no consumer currently distinguishes them. This feature must introduce that distinction for fingerprint purposes. The current config shape is an object (`watch:` with a `files` list), not a bare list; the epic's `watch: []` / `watch: [files]` shorthand maps onto "the `watch:` key is present" (explicit, so stable) versus absent (so unstable). Any change to the config model or its parsing to make the two states distinguishable is in scope. +- **FR-019**: The watch configuration MUST be able to represent absent (`None`) and explicitly-empty (`[]`) as distinct states, and the fingerprint computation MUST branch on that distinction (absent -> fold commit id; explicitly-empty -> do not). NOTE: today the closure builder treats "watch absent" and "watch present but with no files" identically (both skip closure expansion), so no consumer currently distinguishes them. This feature must introduce that distinction for fingerprint purposes. The current config shape is an object (`watch:` with a `files` list), not a bare list; the epic's `watch: []` / `watch: [files]` shorthand maps onto "the `watch:` key is present" (explicit, so stable) versus absent (so unstable). Any change to the config model or its parsing to make the two states distinguishable is in scope. **Amended by IFC-2952 (see FR-016):** the absent-vs-empty distinction only governs Python transforms and generators; for Jinja2 both states produce a stable fingerprint when the closure is complete, because Jinja2 completeness is sound on its own. #### Scope guards and completeness From cab4002fd9be1c9bc7bbf4ab74d4c717e10eb7ef Mon Sep 17 00:00:00 2001 From: Aaron McCarty Date: Mon, 3 Aug 2026 16:22:58 -0500 Subject: [PATCH 73/75] perf(diff): batch node field summary retrieval over paged queries (#10106) * perf(diff): batch node field summary retrieval over paged queries Aggregating every changed node's altered field names in a single query does not scale with large diffs and can exhaust the database heap. Page the query over changed nodes instead, with each row carrying one node's altered fields, and fold the per-node summaries into per-kind NodeDiffFieldSummary instances in the repository. Co-Authored-By: Claude Fable 5 * use Query.limit/offset correctly * update dev knowledge with more on limit/offset for Queries * test for paging correctly on an empty diff --------- Co-authored-by: Claude Fable 5 --- backend/infrahub/core/diff/model/path.py | 14 ++ .../infrahub/core/diff/query/field_summary.py | 92 +++++------ .../core/diff/repository/repository.py | 31 +++- .../repository/test_diff_field_summaries.py | 147 +++++++++++++++++- backend/tests/helpers/db_query_counter.py | 56 +++++++ dev/knowledge/backend/query-pattern.md | 46 ++++++ 6 files changed, 327 insertions(+), 59 deletions(-) create mode 100644 backend/tests/helpers/db_query_counter.py diff --git a/backend/infrahub/core/diff/model/path.py b/backend/infrahub/core/diff/model/path.py index 6ac24b0a446..0e083ed423f 100644 --- a/backend/infrahub/core/diff/model/path.py +++ b/backend/infrahub/core/diff/model/path.py @@ -121,6 +121,20 @@ def add_relationship_node_uuid(self, name: str, node_uuid: str) -> None: """Record that `node_uuid` is a node of this kind whose relationship `name` changed.""" self.relationship_node_uuids.setdefault(name, set()).add(node_uuid) + def merge(self, other: NodeDiffFieldSummary) -> None: + """Fold another summary of the same kind into this one. + + Raises: + ValueError: If the other summary is for a different kind. + + """ + if other.kind != self.kind: + raise ValueError(f"Cannot merge summary for kind {other.kind} into summary for kind {self.kind}") + for name, node_uuids in other.attribute_node_uuids.items(): + self.attribute_node_uuids.setdefault(name, set()).update(node_uuids) + for name, node_uuids in other.relationship_node_uuids.items(): + self.relationship_node_uuids.setdefault(name, set()).update(node_uuids) + @property def attribute_names(self) -> set[str]: return set(self.attribute_node_uuids) diff --git a/backend/infrahub/core/diff/query/field_summary.py b/backend/infrahub/core/diff/query/field_summary.py index 645f6b044c7..e350f77478b 100644 --- a/backend/infrahub/core/diff/query/field_summary.py +++ b/backend/infrahub/core/diff/query/field_summary.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from collections.abc import Generator from typing import Any from infrahub.core.constants import DiffAction @@ -8,29 +8,29 @@ from ..model.path import NodeDiffFieldSummary, TrackingId -@dataclass -class FieldNodeUuidsRow: - """One changed field of a kind and the uuids of the nodes that changed it, as projected by the query.""" - - name: str - node_uuids: list[str] - - class EnrichedDiffNodeFieldSummaryQuery(Query): - """Get node kind and names of all altered attributes and relationships for each kind.""" + """Get the names of all altered attributes and relationships for one page of changed nodes. + + Pagination is over the changed nodes, strictly ordered, with each row carrying every altered + field name of one node; aggregating the fields of every node in one transaction does not scale + with large diffs. + """ name = "enriched_diff_node_field_summary" type = QueryType.READ + insert_limit = False def __init__( self, diff_branch_name: str, + limit: int, tracking_id: TrackingId | None = None, diff_id: str | None = None, **kwargs: Any, ) -> None: super().__init__(**kwargs) self.diff_branch_name = diff_branch_name + self.limit = limit self.tracking_id = tracking_id self.diff_id = diff_id @@ -42,6 +42,8 @@ async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: # noqa "diff_branch": self.diff_branch_name, "tracking_id": self.tracking_id.serialize() if self.tracking_id else None, "diff_id": self.diff_id, + "node_offset": self.offset or 0, + "node_limit": self.limit, } query = """ MATCH (diff_root:DiffRoot) @@ -51,48 +53,40 @@ async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: # noqa AND (diff_root.uuid = $diff_id OR $diff_id IS NULL) OPTIONAL MATCH (diff_root)-[:DIFF_HAS_NODE]->(n:DiffNode) WHERE n.action <> $unchanged_str - WITH DISTINCT diff_root, n.kind AS kind - CALL (diff_root, kind) { - OPTIONAL MATCH (diff_root)-[:DIFF_HAS_NODE]->(n:DiffNode {kind: kind})-[:DIFF_HAS_ATTRIBUTE]->(a:DiffAttribute) - WHERE n.action <> $unchanged_str - AND a.action <> $unchanged_str - WITH a.name AS attr_name, collect(DISTINCT n.uuid) AS attr_node_uuids - WHERE attr_name IS NOT NULL - RETURN collect({name: attr_name, node_uuids: attr_node_uuids}) AS attr_name_uuids + WITH n + ORDER BY n.uuid, elementId(n) + SKIP $node_offset + LIMIT $node_limit + CALL (n) { + OPTIONAL MATCH (n)-[:DIFF_HAS_ATTRIBUTE]->(a:DiffAttribute) + WHERE a.action <> $unchanged_str + RETURN collect(DISTINCT a.name) AS attr_names } - WITH diff_root, kind, attr_name_uuids - CALL (diff_root, kind) { - OPTIONAL MATCH (diff_root)-[:DIFF_HAS_NODE]->(n:DiffNode {kind: kind})-[:DIFF_HAS_RELATIONSHIP]->(r:DiffRelationship) - WHERE n.action <> $unchanged_str - AND r.action <> $unchanged_str - WITH r.name AS rel_name, collect(DISTINCT n.uuid) AS rel_node_uuids - WHERE rel_name IS NOT NULL - RETURN collect({name: rel_name, node_uuids: rel_node_uuids}) AS rel_name_uuids + CALL (n) { + OPTIONAL MATCH (n)-[:DIFF_HAS_RELATIONSHIP]->(r:DiffRelationship) + WHERE r.action <> $unchanged_str + RETURN collect(DISTINCT r.name) AS rel_names } """ self.add_to_query(query=query) - self.order_by = ["kind"] - self.return_labels = ["kind", "attr_name_uuids", "rel_name_uuids"] + self.return_labels = ["n.kind AS kind", "n.uuid AS node_uuid", "attr_names", "rel_names"] - async def get_field_summaries(self) -> list[NodeDiffFieldSummary]: - field_summaries = [] - for result in self.get_results(): - kind = result.get_as_type(label="kind", return_type=str) - attribute_node_uuids = self._to_field_uuids( - result.get_as_list_of_type(label="attr_name_uuids", return_type=FieldNodeUuidsRow) - ) - relationship_node_uuids = self._to_field_uuids( - result.get_as_list_of_type(label="rel_name_uuids", return_type=FieldNodeUuidsRow) - ) - if attribute_node_uuids or relationship_node_uuids: - field_summaries.append( - NodeDiffFieldSummary( - kind=kind, - attribute_node_uuids=attribute_node_uuids, - relationship_node_uuids=relationship_node_uuids, - ) - ) - return field_summaries + def get_node_field_rows(self) -> Generator[NodeDiffFieldSummary, None, None]: + """Yield a single-node field summary for each changed node in this page. - def _to_field_uuids(self, rows: list[FieldNodeUuidsRow]) -> dict[str, set[str]]: - return {row.name: set(row.node_uuids) for row in rows} + A node whose fields are all unchanged still yields a summary (with empty field maps) so the + caller can count the nodes consumed from this page. A diff root with no changed nodes at all + produces one node-less row instead; it is skipped here, which is safe for the caller's + consumed-node count because null nodes sort after every real node. + """ + for result in self.get_results(): + kind = result.get_as_str("kind") + node_uuid = result.get_as_str("node_uuid") + if not kind or not node_uuid: + continue + summary = NodeDiffFieldSummary(kind=kind) + for attr_name in result.get_as_list_of_type(label="attr_names", return_type=str): + summary.add_attribute_node_uuid(name=attr_name, node_uuid=node_uuid) + for rel_name in result.get_as_list_of_type(label="rel_names", return_type=str): + summary.add_relationship_node_uuid(name=rel_name, node_uuid=node_uuid) + yield summary diff --git a/backend/infrahub/core/diff/repository/repository.py b/backend/infrahub/core/diff/repository/repository.py index 2af17832ec9..fb292bfdf74 100644 --- a/backend/infrahub/core/diff/repository/repository.py +++ b/backend/infrahub/core/diff/repository/repository.py @@ -588,11 +588,32 @@ async def get_affected_node_uuids( async def get_node_field_summaries( self, diff_branch_name: str, tracking_id: TrackingId | None = None, diff_id: str | None = None ) -> list[NodeDiffFieldSummary]: - query = await EnrichedDiffNodeFieldSummaryQuery.init( - db=self.db, diff_branch_name=diff_branch_name, tracking_id=tracking_id, diff_id=diff_id - ) - await query.execute(db=self.db) - return await query.get_field_summaries() + node_limit = config.SETTINGS.database.query_size_limit + node_offset = 0 + summaries_by_kind: dict[str, NodeDiffFieldSummary] = {} + while True: + query = await EnrichedDiffNodeFieldSummaryQuery.init( + db=self.db, + diff_branch_name=diff_branch_name, + tracking_id=tracking_id, + diff_id=diff_id, + offset=node_offset, + limit=node_limit, + ) + await query.execute(db=self.db) + num_nodes = 0 + for node_summary in query.get_node_field_rows(): + num_nodes += 1 + if not node_summary.attribute_node_uuids and not node_summary.relationship_node_uuids: + continue + kind_summary = summaries_by_kind.setdefault( + node_summary.kind, NodeDiffFieldSummary(kind=node_summary.kind) + ) + kind_summary.merge(node_summary) + if num_nodes < node_limit: + break + node_offset += node_limit + return list(summaries_by_kind.values()) async def mark_tracking_ids_merged(self, tracking_ids: list[TrackingId]) -> None: query = await EnrichedDiffMergedTrackingIdQuery.init(db=self.db, tracking_ids=tracking_ids) diff --git a/backend/tests/component/core/diff/repository/test_diff_field_summaries.py b/backend/tests/component/core/diff/repository/test_diff_field_summaries.py index 411c9f13a17..03c6c7f497e 100644 --- a/backend/tests/component/core/diff/repository/test_diff_field_summaries.py +++ b/backend/tests/component/core/diff/repository/test_diff_field_summaries.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from typing import Generator import pytest @@ -11,10 +12,12 @@ NodeDiffFieldSummary, ) from infrahub.core.diff.parent_node_adder import DiffParentNodeAdder +from infrahub.core.diff.query.field_summary import EnrichedDiffNodeFieldSummaryQuery from infrahub.core.diff.repository.deserializer import EnrichedDiffDeserializer from infrahub.core.diff.repository.repository import DiffRepository from infrahub.core.timestamp import Timestamp from infrahub.database import InfrahubDatabase +from tests.helpers.db_query_counter import CountingInfrahubDatabase from tests.helpers.diff_factories import ( EnrichedAttributeFactory, EnrichedNodeFactory, @@ -25,6 +28,24 @@ from .base import DiffRepositoryTestBase +@dataclass +class PageSizeCase: + name: str + query_size_limit: int + expected_query_count: int + + +NUM_CHANGED_NODES = 10 + +# One query per page, where the last page is the first to come back shorter than the page size. A +# node count that is an exact multiple of the page size therefore needs one extra, empty page. +PAGE_SIZE_CASES = [ + PageSizeCase(name="partial_last_page", query_size_limit=4, expected_query_count=3), + PageSizeCase(name="node_count_exact_multiple_of_page", query_size_limit=5, expected_query_count=3), + PageSizeCase(name="single_page", query_size_limit=50, expected_query_count=1), +] + + class TestDiffNodeFieldSummaries(DiffRepositoryTestBase): base_branch_name: str = "main" diff_branch_name: str = "diff" @@ -32,18 +53,33 @@ class TestDiffNodeFieldSummaries(DiffRepositoryTestBase): diff_to_time = Timestamp("2024-06-15T18:49:40Z") @pytest.fixture - def diff_repository(self, db: InfrahubDatabase) -> Generator[DiffRepository, None, None]: + def database_settings(self) -> Generator[None, None, None]: original_depth = config.SETTINGS.database.max_depth_search_hierarchy original_size = config.SETTINGS.database.query_size_limit config.SETTINGS.database.max_depth_search_hierarchy = 10 config.SETTINGS.database.query_size_limit = 50 - diff_repository = DiffRepository( - db=db, deserializer=EnrichedDiffDeserializer(DiffParentNodeAdder()), max_save_batch_size=30 - ) - yield diff_repository + yield config.SETTINGS.database.max_depth_search_hierarchy = original_depth config.SETTINGS.database.query_size_limit = original_size + @pytest.fixture + def diff_repository(self, db: InfrahubDatabase, database_settings: None) -> DiffRepository: + return DiffRepository( + db=db, deserializer=EnrichedDiffDeserializer(DiffParentNodeAdder()), max_save_batch_size=30 + ) + + @pytest.fixture + def counting_db(self, db: InfrahubDatabase) -> CountingInfrahubDatabase: + return CountingInfrahubDatabase.from_db(db=db) + + @pytest.fixture + def counting_diff_repository( + self, counting_db: CountingInfrahubDatabase, database_settings: None + ) -> DiffRepository: + return DiffRepository( + db=counting_db, deserializer=EnrichedDiffDeserializer(DiffParentNodeAdder()), max_save_batch_size=30 + ) + def _build_named_field_node( self, kind: str, @@ -176,3 +212,104 @@ async def test_get_node_field_summaries_excludes_other_diffs( diff_branch_name=requested_branch_name, diff_id=requested_diff.uuid ) assert retrieved_by_diff_id == expected_summaries + + async def test_get_node_field_summaries_empty_diff( + self, + counting_diff_repository: DiffRepository, + counting_db: CountingInfrahubDatabase, + reset_database: None, + ) -> None: + """A diff root with no diff nodes at all yields no summaries, and costs a single query. + + Such a root still produces one node-less row, which occupies a page slot without being a node. + Counting that row as a consumed node would keep pagination going past the only page, so the + page size here is one: it is the only size at which that miscount changes the query count. + """ + tracking_id = BranchTrackingId(name=self.diff_branch_name) + enriched_diff = EnrichedRootFactory.build( + base_branch_name=self.base_branch_name, + diff_branch_name=self.diff_branch_name, + from_time=self.diff_from_time, + to_time=self.diff_to_time, + nodes=set(), + tracking_id=tracking_id, + ) + await self._save_single_diff( + diff_repository=counting_diff_repository, enriched_diff=enriched_diff, do_summary_counts=False + ) + + config.SETTINGS.database.query_size_limit = 1 + counting_db.reset_counts() + retrieved = await counting_diff_repository.get_node_field_summaries( + diff_branch_name=self.diff_branch_name, tracking_id=tracking_id + ) + assert retrieved == [] + assert counting_db.count_for(EnrichedDiffNodeFieldSummaryQuery.name) == 1 + + @pytest.mark.parametrize("case", PAGE_SIZE_CASES, ids=lambda c: c.name) + async def test_get_node_field_summaries_batched( + self, + counting_diff_repository: DiffRepository, + counting_db: CountingInfrahubDatabase, + reset_database: None, + case: PageSizeCase, + ) -> None: + """Per-kind summaries are complete, and cost one query per page, when nodes span pages. + + Ten changed nodes of two kinds are saved, so each page size below ten forces every kind + across a page boundary; one node has only unchanged fields, so it fills a page slot without + contributing a summary. + + The query count pins the retrieval cost. Improperly configured Query subclasses can cause + duplicate queries to run. + """ + tracking_id = BranchTrackingId(name=self.diff_branch_name) + kinds = ["TestingKindAlpha", "TestingKindBravo"] + nodes: set[EnrichedDiffNode] = set() + expected_by_kind: dict[str, NodeDiffFieldSummary] = {} + for index in range(NUM_CHANGED_NODES - 1): + kind = kinds[index % len(kinds)] + node = self._build_named_field_node( + kind=kind, + node_action=DiffAction.UPDATED, + attribute_actions={ + "shared_attr": DiffAction.UPDATED, + f"attr_{index}": DiffAction.ADDED, + "quiet_attr": DiffAction.UNCHANGED, + }, + relationship_actions={"shared_rel": DiffAction.UPDATED}, + ) + nodes.add(node) + expected = expected_by_kind.setdefault(kind, NodeDiffFieldSummary(kind=kind)) + expected.add_attribute_node_uuid(name="shared_attr", node_uuid=node.uuid) + expected.add_attribute_node_uuid(name=f"attr_{index}", node_uuid=node.uuid) + expected.add_relationship_node_uuid(name="shared_rel", node_uuid=node.uuid) + nodes.add( + self._build_named_field_node( + kind=kinds[0], + node_action=DiffAction.UPDATED, + attribute_actions={"quiet_attr": DiffAction.UNCHANGED}, + relationship_actions={"quiet_rel": DiffAction.UNCHANGED}, + ) + ) + enriched_diff = EnrichedRootFactory.build( + base_branch_name=self.base_branch_name, + diff_branch_name=self.diff_branch_name, + from_time=self.diff_from_time, + to_time=self.diff_to_time, + nodes=nodes, + tracking_id=tracking_id, + ) + await self._save_single_diff( + diff_repository=counting_diff_repository, enriched_diff=enriched_diff, do_summary_counts=False + ) + + config.SETTINGS.database.query_size_limit = case.query_size_limit + counting_db.reset_counts() + retrieved = await counting_diff_repository.get_node_field_summaries( + diff_branch_name=self.diff_branch_name, tracking_id=tracking_id + ) + + assert len(retrieved) == len(expected_by_kind) + assert {summary.kind: summary for summary in retrieved} == expected_by_kind + assert counting_db.count_for(EnrichedDiffNodeFieldSummaryQuery.name) == case.expected_query_count diff --git a/backend/tests/helpers/db_query_counter.py b/backend/tests/helpers/db_query_counter.py new file mode 100644 index 00000000000..c65c107431b --- /dev/null +++ b/backend/tests/helpers/db_query_counter.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from collections import Counter +from typing import TYPE_CHECKING, Any + +from infrahub.database import InfrahubDatabase, InfrahubDatabaseMode + +if TYPE_CHECKING: + from neo4j import Record + + from infrahub.core.query import QueryType + + +class CountingInfrahubDatabase(InfrahubDatabase): + """Database that records how many queries were executed, keyed by query name.""" + + def __init__(self, query_counts: Counter[str] | None = None, **kwargs: Any) -> None: + super().__init__(**kwargs) + # shared by reference so counts recorded by derived session/transaction instances land here + self.query_counts: Counter[str] = query_counts if query_counts is not None else Counter() + + @classmethod + def from_db(cls, db: InfrahubDatabase) -> CountingInfrahubDatabase: + """Build a counting database on the driver of an existing one.""" + return cls( + mode=InfrahubDatabaseMode.DRIVER, + driver=db._driver, + db_type=db.db_type, + default_neo4j_runtime=db.default_neo4j_runtime, + queries_names_to_config=db.queries_names_to_config, + ) + + def get_context(self) -> dict[str, Any]: + ctx = super().get_context() + ctx["query_counts"] = self.query_counts + return ctx + + def count_for(self, name: str) -> int: + return self.query_counts[name] + + def reset_counts(self) -> None: + self.query_counts.clear() + + async def execute_query_with_metadata( + self, + query: str, + params: dict[str, Any] | None = None, + name: str = "undefined", + context: dict[str, str] | None = None, + type: QueryType | None = None, + timeout_seconds: float | None = None, + ) -> tuple[list[Record], dict[str, Any]]: + self.query_counts[name] += 1 + return await super().execute_query_with_metadata( + query=query, params=params, name=name, context=context, type=type, timeout_seconds=timeout_seconds + ) diff --git a/dev/knowledge/backend/query-pattern.md b/dev/knowledge/backend/query-pattern.md index 23825905667..c5ffd0443fe 100644 --- a/dev/knowledge/backend/query-pattern.md +++ b/dev/knowledge/backend/query-pattern.md @@ -171,6 +171,52 @@ class MyQuery(Query): self.add_to_query("RETURN n.uuid AS uuid, n.name AS name LIMIT 100") # Manual pagination ``` +#### Paginating a query that expands each row + +The automatic clause is appended *after* the `RETURN`, so it bounds the rows a query returns, not the work it does to produce them. When a query expands each matched row — a per-row `CALL` subquery, a `collect()` over a traversal — that expansion has already run for every match by the time the automatic `LIMIT` applies, and an `ORDER BY` there forces every expanded row to be materialized before sorting. A query that must bound *that* work takes its page in the body, before the expansion: + +```python +class PagedNodeFieldsQuery(Query): + name = "paged_node_fields" + type = QueryType.READ + insert_limit = False + + def __init__(self, limit: int, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.limit = limit + + async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: + self.params = {"page_offset": self.offset or 0, "page_limit": self.limit} + self.add_to_query(""" + MATCH (n:Node) + WITH n + ORDER BY n.uuid, elementId(n) + SKIP $page_offset + LIMIT $page_limit + CALL (n) { + OPTIONAL MATCH (n)-[:HAS_ATTRIBUTE]->(a:Attribute) + RETURN collect(DISTINCT a.name) AS attr_names + } + """) + self.return_labels = ["n.uuid AS node_uuid", "attr_names"] +``` + +Three details make this correct: + +- Type the page size as `limit: int`, not `int | None`: a query that pages itself has no meaningful unpaged mode, and a required constructor parameter says so at the call site instead of failing later. +- Read the bounds from the base class's `self.limit` and `self.offset` rather than adding parallel attributes, and bind them as query parameters. Reusing the base fields keeps one source of truth for the page size and is what `execute()` inspects (see below); parameters rather than interpolated literals let every page reuse one compiled plan. +- Order strictly. `SKIP`/`LIMIT` over an unordered match can return one row on two pages and another on none; `elementId(n)` breaks ties when the sort property is not unique. + +`GetPathDetailsBranchQuery` in `backend/infrahub/core/migrations/query/path_details.py` is the reference implementation, driven by the caller loop in `backend/infrahub/core/migrations/helpers/attribute_recompute.py`. + +#### Always set self.limit on a self-paging read + +`Query.execute()` treats a READ with neither `limit` nor `offset` as unpaginated and routes it through `query_with_size_limit()`, which re-runs the query once per `database.query_size_limit` rows with a growing `SKIP` appended after the `RETURN`. Wrapped around a query that already pages itself, that costs one extra execution of the whole query per full page; the extra run's rows are all discarded by the outer `SKIP`, so results stay correct and only the cost shows up. + +With `insert_limit = False` it is worse than wasteful. The wrapper cannot append its `SKIP`/`LIMIT`, so every iteration re-sends identical text, and the loop ends only because a batch came back shorter than `query_size_limit`. A query whose own bound is greater than or equal to `query_size_limit` never produces a short batch, so the loop never terminates and keeps appending the same rows. + +Because the wasted execution changes no returned value, assertions on query results cannot detect it. `CountingInfrahubDatabase` in `backend/tests/helpers/db_query_counter.py` counts executions by query name, so a test can assert how many queries a paged read issues. + ### Branch-Aware Edge Resolution Every edge in the graph has branch/temporal properties (`branch`, `branch_level`, `from`, `to`, `status`). When traversing multiple edges in a single query, filter each edge independently to resolve the correct active version: From f8a57400a361598bf036c2c439fa6fa7ab652194 Mon Sep 17 00:00:00 2001 From: Aaron McCarty Date: Mon, 3 Aug 2026 16:23:18 -0500 Subject: [PATCH 74/75] docs: add merge failure and recovery docs (#10080) * update error message with correct CLI command * add infrahub recover CLI reference * add docs for merge failure and recovery --- backend/infrahub/branch/status_checker.py | 4 +- backend/infrahub/errors/catalogue.py | 2 +- docs/docs/branches/merge.mdx | 61 +++++++++++++++++++ docs/docs/reference/error-catalogue.mdx | 4 +- .../infrahub-cli/infrahub-recover.mdx | 46 ++++++++++++++ docs/sidebars.ts | 1 + schema/error-catalogue.json | 2 +- tasks/docs.py | 1 + 8 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 docs/docs/reference/infrahub-cli/infrahub-recover.mdx diff --git a/backend/infrahub/branch/status_checker.py b/backend/infrahub/branch/status_checker.py index de1f7b34486..6f36947b834 100644 --- a/backend/infrahub/branch/status_checker.py +++ b/backend/infrahub/branch/status_checker.py @@ -24,7 +24,7 @@ MERGE_RECOVERY_REQUIRED_MESSAGE = ( "A previous merge failed and left the default branch protected. Writes stay blocked until an " - "administrator runs `infrahub recover`. Please contact an administrator." + "administrator runs `infrahub recover merge`. Please contact an administrator." ) @@ -58,7 +58,7 @@ async def check_merging_status(self, branch: Branch) -> None: - MERGING: transient — the default branch becomes writable again once the merge completes, so the target gate raises the retryable MergeInProgressError, and - MERGE_FAILED: durable — a previous merge died, so the gate raises MergeRecoveryRequiredError - (a distinct, non-retryable code) until an administrator runs ``infrahub recover``. + (a distinct, non-retryable code) until an administrator runs ``infrahub recover merge``. If the cache lookup fails — unreachable backend, or a present-but-corrupt value that cannot be interpreted as "no merge in progress" — the gate falls back to the durable branch status in the diff --git a/backend/infrahub/errors/catalogue.py b/backend/infrahub/errors/catalogue.py index c34f6c9b5fa..e9438ee297c 100644 --- a/backend/infrahub/errors/catalogue.py +++ b/backend/infrahub/errors/catalogue.py @@ -178,7 +178,7 @@ class CatalogueEntry(BaseModel): CatalogueEntry( description=( "The write was rejected because a previous branch merge failed and left the default " - "branch protected. Recovery is required: an administrator must run `infrahub recover`. " + "branch protected. Recovery is required: an administrator must run `infrahub recover merge`. " "Unlike MERGE_IN_PROGRESS this is not retryable." ), stability="evolving", diff --git a/docs/docs/branches/merge.mdx b/docs/docs/branches/merge.mdx index cca1a54aa4b..f9c52de3479 100644 --- a/docs/docs/branches/merge.mdx +++ b/docs/docs/branches/merge.mdx @@ -62,10 +62,71 @@ Once merged, a branch enters a frozen state and no further mutations are allowed The merged source branch can be deleted manually or automatically — see [Delete a branch](./delete.mdx) for the deletion options, including the `delete_branch_after_merge` configuration that removes branches automatically right after a successful merge. +## When a merge fails + +A merge is not a single write and it can be too large to be wrapped in a transaction. While the write protection described above is in place, the merge copies the data into the default branch and, when the source branch changed the schema, applies the schema migrations that follow from it. Only once all of that has succeeded does the merge reach its point of no return, where the source branch becomes frozen and the write protection lifts. All of that work runs on one worker, and that worker can disappear part-way through — the process crashes or is killed, its container is restarted, or the database becomes unreachable while the merge is mid-flight. + +Infrahub is designed so that this leaves a *recoverable* state rather than a silently half-merged default branch. The write protection is not lifted on failure: it stays in place, so nothing writes on top of a partial merge, and the branch keeps its merge status until an administrator recovers it. The trade-off is deliberate — the default branch rejects writes for longer in exchange for never presenting a partially merged graph as if the merge had succeeded. + +### How a failed merge is detected + +A merge cannot report its own death, so Infrahub infers it. A background check runs once a minute and looks for a branch still in the merging state whose merge worker is no longer among the live workers. When it finds one, and the merge has been running longer than the grace period, it records the branch as having failed and escalates the write protection from the transient in-progress block to a recovery-required block. + +:::note +A failed merge is therefore not flagged the instant the worker dies. Expect a few minutes to pass before the rejection changes from `MERGE_IN_PROGRESS` to `MERGE_RECOVERY_REQUIRED`. +::: + +A merge whose worker is still alive is never flagged, however long it runs. + +### The error clients see + +Once the merge is flagged as failed, writes to the default branch and to the merge source branch are rejected with the structured code `MERGE_RECOVERY_REQUIRED` (HTTP status 423): + +```json +{ + "extensions": { + "code": "MERGE_RECOVERY_REQUIRED", + "http_status": 423, + "data": { + "branch_name": "main", + "merging_branch": "my-feature-branch" + } + } +} +``` + +The accompanying message names the remedy directly: + +> A previous merge failed and left the default branch protected. Writes stay blocked until an administrator runs `infrahub recover merge`. Please contact an administrator. + +This is the important distinction from `MERGE_IN_PROGRESS`, which carries the same HTTP status: `MERGE_IN_PROGRESS` is transient and clears on its own, so retrying is the correct response. `MERGE_RECOVERY_REQUIRED` is durable. It does not clear with time and no amount of retrying will lift it — it requires an administrator to act. Automation should treat the two codes differently rather than retrying both. + +:::warning +Client-side retry logic that matches only on HTTP 423 will retry a failed merge forever. Branch on the `code` in `extensions`, not on the status alone. +::: + +### Recovering the failed merge + +An administrator recovers the branch with the [`infrahub recover merge`](../reference/infrahub-cli/infrahub-recover.mdx) CLI command, run against the Infrahub server: + +```shell +infrahub recover merge +``` + +The command finds the failed merge on its own; naming a branch explicitly restricts it to that branch. It first previews what it found — the branch, when the merge started, and any associated Proposed Change — and asks for confirmation before changing anything. `--yes` skips the prompt for unattended use. + +Recovery reverses the partial merge rather than completing it. It rolls back every default-branch write the merge made, returns the branch and any associated Proposed Change to the open state, and lifts the write protection last, so an interruption part-way through leaves the branch protected rather than exposed. It is idempotent: running it again after a partial recovery re-detects the branch and finishes the job, and running it when there is nothing to recover reports that and makes no changes. + +Once recovery reports success, the default branch is writable again and the branch is back in the state it was in before the merge started. The merge can then be retried. + +By default, recovery acts only on a merge whose worker is confirmed dead. A merge that is stuck without an identifiable worker is ambiguous — it can look the same as a healthy merge whose bookkeeping was lost — so recovering it requires `--force`. + ## Related - [Branches](./overview.mdx) — branch lifecycle and concepts - [Proposed Changes](../proposed-changes/overview.mdx) — the recommended path for merging - [Resolve conflicts](./resolve-conflicts.mdx) — handle conflicts before or during merge +- [`infrahub recover`](../reference/infrahub-cli/infrahub-recover.mdx) — CLI reference for recovering a failed merge +- [Error catalogue](../reference/error-catalogue.mdx) — the full `MERGE_IN_PROGRESS` and `MERGE_RECOVERY_REQUIRED` contracts - [Rebase a branch](./rebase.mdx) — keep your branch up-to-date before merging - [Delete a branch](./delete.mdx) — what happens after merging diff --git a/docs/docs/reference/error-catalogue.mdx b/docs/docs/reference/error-catalogue.mdx index b6e4c435c2a..e3e24560d24 100644 --- a/docs/docs/reference/error-catalogue.mdx +++ b/docs/docs/reference/error-catalogue.mdx @@ -286,7 +286,7 @@ The write was rejected because a branch merge is in progress. The block is trans ### MERGE_RECOVERY_REQUIRED -The write was rejected because a previous branch merge failed and left the default branch protected. Recovery is required: an administrator must run `infrahub recover`. Unlike MERGE_IN_PROGRESS this is not retryable. +The write was rejected because a previous branch merge failed and left the default branch protected. Recovery is required: an administrator must run `infrahub recover merge`. Unlike MERGE_IN_PROGRESS this is not retryable. - **Stability**: `evolving` - **HTTP status**: `423` @@ -305,7 +305,7 @@ The write was rejected because a previous branch merge failed and left the defau "data": null, "errors": [ { - "message": "The write was rejected because a previous branch merge failed and left the default branch protected. Recovery is required: an administrator must run `infrahub recover`. Unlike MERGE_IN_PROGRESS this is not retryable.", + "message": "The write was rejected because a previous branch merge failed and left the default branch protected. Recovery is required: an administrator must run `infrahub recover merge`. Unlike MERGE_IN_PROGRESS this is not retryable.", "extensions": { "code": "MERGE_RECOVERY_REQUIRED", "http_status": 423, diff --git a/docs/docs/reference/infrahub-cli/infrahub-recover.mdx b/docs/docs/reference/infrahub-cli/infrahub-recover.mdx new file mode 100644 index 00000000000..7924286c3e1 --- /dev/null +++ b/docs/docs/reference/infrahub-cli/infrahub-recover.mdx @@ -0,0 +1,46 @@ +# `infrahub recover` + +Recover from failed operations. + +**Usage**: + +```console +$ infrahub recover [OPTIONS] COMMAND [ARGS]... +``` + +**Options**: + +* `--install-completion`: Install completion for the current shell. +* `--show-completion`: Show completion for the current shell, to copy it or customize the installation. +* `--help`: Show this message and exit. + +**Commands**: + +* `merge`: Recover a failed branch merge. + +## `infrahub recover merge` + +Recover a failed branch merge. + +Roll back the partial graph merge and reset the branch (and any associated proposed change) to +OPEN, then lift the write protection so the default branch is writable again. Idempotent: a run +with nothing to recover reports so and makes no changes. By default only a merge whose worker is +confirmed dead is recovered; pass --force to also recover a merge stuck with an absent/ambiguous +lock. + +**Usage**: + +```console +$ infrahub recover merge [OPTIONS] [BRANCH] [CONFIG_FILE] +``` + +**Arguments**: + +* `[BRANCH]`: Name of the branch to recover; if omitted, the failed merge is auto-detected. +* `[CONFIG_FILE]`: [env var: INFRAHUB_CONFIG; default: infrahub.toml] + +**Options**: + +* `-y, --yes`: Skip the confirmation prompt. +* `-f, --force`: Recover even when the merge lock is absent/ambiguous, not just when the worker is confirmed dead. +* `--help`: Show this message and exit. diff --git a/docs/sidebars.ts b/docs/sidebars.ts index f9558759482..741c762226b 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -566,6 +566,7 @@ const sidebars: SidebarsConfig = { 'reference/infrahub-cli/infrahub-server', 'reference/infrahub-cli/infrahub-dev', 'reference/infrahub-cli/infrahub-upgrade', + 'reference/infrahub-cli/infrahub-recover', ], }, { diff --git a/schema/error-catalogue.json b/schema/error-catalogue.json index f12c60019ec..e6619e912d8 100644 --- a/schema/error-catalogue.json +++ b/schema/error-catalogue.json @@ -301,7 +301,7 @@ } }, "MERGE_RECOVERY_REQUIRED": { - "description": "The write was rejected because a previous branch merge failed and left the default branch protected. Recovery is required: an administrator must run `infrahub recover`. Unlike MERGE_IN_PROGRESS this is not retryable.", + "description": "The write was rejected because a previous branch merge failed and left the default branch protected. Recovery is required: an administrator must run `infrahub recover merge`. Unlike MERGE_IN_PROGRESS this is not retryable.", "stability": "evolving", "http_status": 423, "data_schema": { diff --git a/tasks/docs.py b/tasks/docs.py index c041fdf59cd..c245f77af17 100644 --- a/tasks/docs.py +++ b/tasks/docs.py @@ -187,6 +187,7 @@ def _generate_infrahub_cli_documentation(context: Context) -> None: ("infrahub.cli.server", "infrahub server", "infrahub-server"), ("infrahub.cli.dev", "infrahub dev", "infrahub-dev"), ("infrahub.cli.upgrade", "infrahub upgrade", "infrahub-upgrade"), + ("infrahub.cli.recover", "infrahub recover", "infrahub-recover"), ) print(" - Generate Infrahub CLI documentation") From ef11b7b101ced1954eedccad694d806bf5736a67 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:14:14 +0300 Subject: [PATCH 75/75] chore(deps-dev): bump brace-expansion (#10112) Bumps the npm_and_yarn group with 1 update in the /docs directory: [brace-expansion](https://github.com/juliangruber/brace-expansion). Updates `brace-expansion` from 1.1.16 to 1.1.18 - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.16...v1.1.18) --- updated-dependencies: - dependency-name: brace-expansion dependency-version: 1.1.18 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package-lock.json | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index a8aa77ea04d..3b235706815 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -4409,9 +4409,9 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -4483,9 +4483,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -7705,16 +7705,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/brace-expansion/node_modules/balanced-match": { @@ -10344,9 +10344,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -16758,9 +16758,9 @@ } }, "node_modules/postcss": { - "version": "8.5.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", - "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "funding": [ { "type": "opencollective", @@ -19629,9 +19629,9 @@ } }, "node_modules/serve-handler/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0",